├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── SELF_CONTAINED_EXAMPLE.md ├── bin └── publish ├── demo ├── .gitignore ├── Gemfile ├── README.md ├── Rakefile ├── app.rb ├── assets │ ├── css │ │ └── app.css │ └── js │ │ └── app.js ├── config.ru ├── config │ ├── credentials.rb │ ├── sequel.rb │ └── shrine.rb ├── db │ └── migrations │ │ ├── 001_create_albums.rb │ │ └── 002_create_photos.rb ├── jobs │ └── attachment │ │ ├── destroy_job.rb │ │ └── promote_job.rb ├── lib │ └── generate_thumbnail.rb ├── models │ ├── album.rb │ └── photo.rb ├── routes │ ├── albums.rb │ ├── base.rb │ └── direct_upload.rb ├── test │ ├── acceptance_test.rb │ ├── fixtures │ │ └── image.jpg │ └── test_helper.rb ├── uploaders │ └── image_uploader.rb └── views │ ├── albums │ ├── _form.erb │ ├── _photo.erb │ ├── index.erb │ ├── new.erb │ └── show.erb │ └── layout.erb ├── doc ├── advantages.md ├── attacher.md ├── carrierwave.md ├── changing_derivatives.md ├── changing_location.md ├── changing_storage.md ├── creating_persistence_plugins.md ├── creating_plugins.md ├── creating_storages.md ├── design.md ├── direct_s3.md ├── external │ ├── articles.md │ ├── extensions.md │ └── misc.md ├── getting_started.md ├── metadata.md ├── multiple_files.md ├── paperclip.md ├── plugins │ ├── activerecord.md │ ├── add_metadata.md │ ├── atomic_helpers.md │ ├── backgrounding.md │ ├── cached_attachment_data.md │ ├── column.md │ ├── data_uri.md │ ├── default_storage.md │ ├── default_url.md │ ├── delete_raw.md │ ├── derivation_endpoint.md │ ├── derivatives.md │ ├── determine_mime_type.md │ ├── download_endpoint.md │ ├── dynamic_storage.md │ ├── entity.md │ ├── form_assign.md │ ├── included.md │ ├── infer_extension.md │ ├── instrumentation.md │ ├── keep_files.md │ ├── metadata_attributes.md │ ├── mirroring.md │ ├── model.md │ ├── module_include.md │ ├── multi_cache.md │ ├── persistence.md │ ├── presign_endpoint.md │ ├── pretty_location.md │ ├── processing.md │ ├── rack_file.md │ ├── rack_response.md │ ├── recache.md │ ├── refresh_metadata.md │ ├── remote_url.md │ ├── remove_attachment.md │ ├── remove_invalid.md │ ├── restore_cached_data.md │ ├── sequel.md │ ├── signature.md │ ├── store_dimensions.md │ ├── tempfile.md │ ├── type_predicates.md │ ├── upload_endpoint.md │ ├── upload_options.md │ ├── url_options.md │ ├── validation.md │ ├── validation_helpers.md │ └── versions.md ├── processing.md ├── refile.md ├── release_notes │ ├── 1.0.0.md │ ├── 1.1.0.md │ ├── 1.2.0.md │ ├── 1.3.0.md │ ├── 1.4.0.md │ ├── 1.4.1.md │ ├── 1.4.2.md │ ├── 2.0.0.md │ ├── 2.0.1.md │ ├── 2.1.0.md │ ├── 2.1.1.md │ ├── 2.10.0.md │ ├── 2.10.1.md │ ├── 2.11.0.md │ ├── 2.12.0.md │ ├── 2.13.0.md │ ├── 2.14.0.md │ ├── 2.15.0.md │ ├── 2.16.0.md │ ├── 2.17.0.md │ ├── 2.18.0.md │ ├── 2.19.0.md │ ├── 2.2.0.md │ ├── 2.3.0.md │ ├── 2.3.1.md │ ├── 2.4.0.md │ ├── 2.4.1.md │ ├── 2.5.0.md │ ├── 2.6.0.md │ ├── 2.6.1.md │ ├── 2.7.0.md │ ├── 2.8.0.md │ ├── 2.9.0.md │ ├── 3.0.0.md │ ├── 3.0.1.md │ ├── 3.1.0.md │ ├── 3.2.0.md │ ├── 3.2.1.md │ ├── 3.2.2.md │ ├── 3.3.0.md │ ├── 3.4.0.md │ ├── 3.5.0.md │ └── 3.6.0.md ├── retrieving_uploads.md ├── securing_uploads.md ├── storage │ ├── file_system.md │ ├── memory.md │ └── s3.md ├── testing.md ├── upgrading_to_3.md └── validation.md ├── lib ├── shrine.rb └── shrine │ ├── attacher.rb │ ├── attachment.rb │ ├── plugins.rb │ ├── plugins │ ├── _persistence.rb │ ├── _urlsafe_serialization.rb │ ├── activerecord.rb │ ├── add_metadata.rb │ ├── atomic_helpers.rb │ ├── backgrounding.rb │ ├── cached_attachment_data.rb │ ├── column.rb │ ├── data_uri.rb │ ├── default_storage.rb │ ├── default_url.rb │ ├── default_url_options.rb │ ├── delete_raw.rb │ ├── derivation_endpoint.rb │ ├── derivatives.rb │ ├── determine_mime_type.rb │ ├── download_endpoint.rb │ ├── dynamic_storage.rb │ ├── entity.rb │ ├── form_assign.rb │ ├── included.rb │ ├── infer_extension.rb │ ├── instrumentation.rb │ ├── keep_files.rb │ ├── metadata_attributes.rb │ ├── mirroring.rb │ ├── model.rb │ ├── module_include.rb │ ├── multi_cache.rb │ ├── presign_endpoint.rb │ ├── pretty_location.rb │ ├── processing.rb │ ├── rack_file.rb │ ├── rack_response.rb │ ├── recache.rb │ ├── refresh_metadata.rb │ ├── remote_url.rb │ ├── remove_attachment.rb │ ├── remove_invalid.rb │ ├── restore_cached_data.rb │ ├── sequel.rb │ ├── signature.rb │ ├── store_dimensions.rb │ ├── tempfile.rb │ ├── type_predicates.rb │ ├── upload_endpoint.rb │ ├── upload_options.rb │ ├── url_options.rb │ ├── validation.rb │ ├── validation_helpers.rb │ └── versions.rb │ ├── storage │ ├── file_system.rb │ ├── linter.rb │ ├── memory.rb │ └── s3.rb │ ├── uploaded_file.rb │ └── version.rb ├── shrine.gemspec ├── test ├── attacher_test.rb ├── attachment_test.rb ├── fixtures │ └── image.jpg ├── integration │ ├── activerecord_backgrounding_test.rb │ └── sequel_backgrounding_test.rb ├── plugin │ ├── activerecord_test.rb │ ├── add_metadata_test.rb │ ├── atomic_helpers_test.rb │ ├── backgrounding_test.rb │ ├── cached_attachment_data_test.rb │ ├── column_test.rb │ ├── data_uri_test.rb │ ├── default_storage_test.rb │ ├── default_url_test.rb │ ├── delete_raw_test.rb │ ├── derivation_endpoint_test.rb │ ├── derivatives_test.rb │ ├── determine_mime_type_test.rb │ ├── download_endpoint_test.rb │ ├── dynamic_storage_test.rb │ ├── entity_test.rb │ ├── form_assign_test.rb │ ├── included_test.rb │ ├── infer_extension_test.rb │ ├── instrumentation_test.rb │ ├── keep_files_test.rb │ ├── metadata_attributes_test.rb │ ├── mirroring_test.rb │ ├── model_test.rb │ ├── module_include_test.rb │ ├── multi_cache_test.rb │ ├── presign_endpoint_test.rb │ ├── pretty_location_test.rb │ ├── processing_test.rb │ ├── rack_file_test.rb │ ├── rack_response_test.rb │ ├── recache_test.rb │ ├── refresh_metadata_test.rb │ ├── remote_url_test.rb │ ├── remove_attachment_test.rb │ ├── remove_invalid_test.rb │ ├── restore_cached_data_test.rb │ ├── sequel_test.rb │ ├── signature_test.rb │ ├── store_dimensions_test.rb │ ├── tempfile_test.rb │ ├── type_predicates_test.rb │ ├── upload_endpoint_test.rb │ ├── upload_options_test.rb │ ├── url_options_test.rb │ ├── urlsafe_serialization_test.rb │ ├── validation_helpers_test.rb │ ├── validation_test.rb │ └── versions_test.rb ├── plugin_test.rb ├── shrine_test.rb ├── storage │ ├── file_system_test.rb │ ├── linter_test.rb │ ├── memory_test.rb │ └── s3_test.rb ├── support │ ├── activerecord.rb │ ├── fakeio.rb │ ├── file_helper.rb │ ├── logging_helper.rb │ ├── sequel.rb │ └── shrine_helper.rb ├── test_helper.rb └── uploaded_file_test.rb └── website ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.json ├── src ├── css │ └── customTheme.css └── pages │ ├── _demo.md │ ├── _sponsors.js │ └── index.js └── static └── img ├── favicon.ico └── logo.png /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | env: 10 | RACK_ENV: development 11 | S3_REAL: true 12 | S3_REGION: us-east-1 13 | S3_BUCKET: minio-bucket 14 | S3_ACCESS_KEY_ID: weak_key 15 | S3_SECRET_ACCESS_KEY: weak_key 16 | S3_ENDPOINT: http://localhost:9000 17 | MINIO_ACCESS_KEY: weak_key 18 | MINIO_SECRET_KEY: weak_key 19 | 20 | jobs: 21 | tests: 22 | runs-on: ubuntu-latest 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | ruby: 27 | - "2.3" 28 | - "2.4" 29 | - "2.5" 30 | - "2.6" 31 | - "2.7" 32 | - "3.0" 33 | - "3.1" 34 | - "3.2" 35 | - "3.3" 36 | - "jruby-9.4" 37 | 38 | steps: 39 | - uses: actions/checkout@v3 40 | 41 | - name: Set up Ruby 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby }} 45 | bundler-cache: true 46 | rubygems: ${{ matrix.ruby <= '2.5' && '3.3.26' || 'latest' }} 47 | 48 | - name: Set up Minio 49 | run: | 50 | mkdir -p "${GITHUB_WORKSPACE}"/minio/data/minio-bucket 51 | wget -nc -O "${GITHUB_WORKSPACE}"/minio/minio https://dl.min.io/server/minio/release/linux-amd64/minio 52 | chmod +x "${GITHUB_WORKSPACE}"/minio/minio 53 | ${GITHUB_WORKSPACE}/minio/minio server ${GITHUB_WORKSPACE}/minio/data --address localhost:9000 &>${GITHUB_WORKSPACE}/minio/data/server.log & 54 | 55 | - name: Run tests 56 | run: bundle exec rake test 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg/ 3 | .env 4 | website/build 5 | coverage/ 6 | node_modules 7 | .docusaurus 8 | gemfiles/*.lock 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Issue Guidelines 2 | ================ 3 | 4 | 1. Issues should only be created for things that are definitely bugs. If you 5 | are not sure that the behavior is a bug, ask about it on [Github Discussions] or [Discourse]. Otherwise Github gets overwhelmed with issues and it is very difficult for the maintainers to manage. 6 | 7 | 2. If you are sure it is a bug, then post a complete description of the issue, 8 | the simplest possible [self-contained example] showing the problem (please do review the link), and the full backtrace of any exception. 9 | 10 | Pull Request Guidelines 11 | ======================= 12 | 13 | 1. Try to include tests for all new features and substantial bug 14 | fixes. 15 | 16 | 2. Try to include documentation for all new features. In most cases 17 | this should include RDoc method documentation, but updates to the 18 | README is also appropriate in some cases. 19 | 20 | 3. Follow the style conventions of the surrounding code. In most 21 | cases, this is standard ruby style. 22 | 23 | Understanding the codebase 24 | ========================== 25 | 26 | * The [Design of Shrine] guide gives a general overview of Shrine's core 27 | classes. 28 | 29 | * The [Creating a New Plugin] guide and the [Plugin system of Sequel and Roda] 30 | article explain how Shrine's plugin system works. 31 | 32 | * The [Notes on study of shrine implementation] article gives an in-depth 33 | walkthrough through the Shrine codebase. 34 | 35 | Running tests 36 | ============= 37 | 38 | The test suite requires that you have the following installed: 39 | 40 | * [libmagic] 41 | * [SQLite] 42 | * [libvips] - please download the appropriate package suiting your operating system. 43 | 44 | With Hombrew this would be: 45 | 46 | ```sh 47 | $ brew install libmagic sqlite libvips 48 | ``` 49 | 50 | The test suite is best run using Rake: 51 | 52 | ``` 53 | $ rake test 54 | ``` 55 | 56 | Code of Conduct 57 | =============== 58 | 59 | Everyone interacting in the Shrine project’s codebases, issue trackers, chat 60 | rooms, and mailing lists is expected to follow the [Shrine code of conduct]. 61 | 62 | [Github Discussions]: https://github.com/shrinerb/shrine/discussions 63 | [Discourse]: https://discourse.shrinerb.com 64 | [Shrine code of conduct]: https://github.com/shrinerb/shrine/blob/master/CODE_OF_CONDUCT.md 65 | [libmagic]: https://github.com/threatstack/libmagic 66 | [libvips]: https://github.com/libvips/libvips/wiki 67 | [SQLite]: https://www.sqlite.org 68 | [Design of Shrine]: /doc/design.md#readme 69 | [Creating a New Plugin]: /doc/creating_plugins.md#readme 70 | [Plugin system of Sequel and Roda]: https://twin.github.io/the-plugin-system-of-sequel-and-roda/ 71 | [Notes on study of shrine implementation]: https://bibwild.wordpress.com/2018/09/12/notes-on-study-of-shrine-implementation/ 72 | [self-contained example]: https://github.com/shrinerb/shrine/blob/master/SELF_CONTAINED_EXAMPLE.md 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.11.4 2 | 3 | WORKDIR /app/website 4 | 5 | EXPOSE 3000 35729 6 | COPY ./docs /app/docs 7 | COPY ./website /app/website 8 | RUN yarn install 9 | 10 | CMD ["yarn", "start"] 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "pry" 6 | gem "simplecov" 7 | 8 | gem "hanna", require: false 9 | 10 | gem "activerecord-jdbcsqlite3-adapter", "~> 70.0", platform: :jruby if RUBY_ENGINE == "jruby" 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2019 Janko Marohnić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "rdoc/task" 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | t.warning = false 9 | end 10 | 11 | task default: :test 12 | 13 | RDoc::Task.new do |t| 14 | t.rdoc_dir = "website/build/rdoc" 15 | t.options += [ 16 | "--line-numbers", 17 | "--title", "Shrine: Toolkit for file uploads", 18 | "--markup", "markdown", 19 | "--format", "hanna", 20 | "--main", "README.md", 21 | "--visibility", "public", 22 | ] 23 | t.rdoc_files.add Dir[ 24 | "README.md", 25 | "CHANGELOG.md", 26 | "lib/**/*.rb", 27 | "doc/*.md", 28 | "doc/release_notes/*.md", 29 | ] 30 | end 31 | 32 | namespace :website do 33 | task :publish => :build do 34 | sh "git switch gh-pages" 35 | sh "cp -R website/build/* ." 36 | sh "git add --all" 37 | sh "git commit -m 'Update website'" 38 | sh "git push origin gh-pages" 39 | sh "git switch master" 40 | end 41 | 42 | task :build do 43 | sh "yarn build", chdir: "website" 44 | Rake::Task["rdoc"].invoke 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /bin/publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd website 4 | npm run build 5 | cd .. 6 | git switch gh-pages 7 | cp -R website/build/* . 8 | git add --all 9 | git commit -m "Update website" 10 | git push origin gh-pages 11 | git switch master 12 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | database.sqlite3 3 | public/uploads 4 | -------------------------------------------------------------------------------- /demo/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Web framework 4 | gem "puma" 5 | gem "roda", "~> 3.36" 6 | 7 | # Rendering 8 | gem "tilt", ">= 2.0.8" 9 | gem "erubi" 10 | gem "forme", "~> 1.8" 11 | 12 | # Database 13 | gem "sequel" 14 | gem "sqlite3" 15 | 16 | # Attachments 17 | gem "shrine", path: ".." 18 | gem "aws-sdk-s3", "~> 1.14" 19 | gem "dotenv" 20 | gem "image_processing", "~> 1.8" 21 | gem "marcel" 22 | gem "fastimage" 23 | gem "dry-monitor" 24 | 25 | # Background jobs 26 | gem "sucker_punch", "~> 2.0" 27 | 28 | # Debugging 29 | gem "pry-byebug" 30 | 31 | # Testing 32 | gem "minitest" 33 | gem "capybara", "~> 3.33" 34 | gem "cuprite" 35 | gem "rake" 36 | -------------------------------------------------------------------------------- /demo/Rakefile: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs << "test" 6 | t.test_files = FileList["test/**/*_test.rb"] 7 | t.warning = false 8 | end 9 | 10 | task default: :test 11 | 12 | namespace :db do 13 | task :migrate, [:version] do |task, args| 14 | db = Sequel.sqlite("database.sqlite3") 15 | Sequel.extension :migration 16 | version = Integer(args[:version]) if args[:version] 17 | Sequel::Migrator.apply(db, "db/migrations", version) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /demo/app.rb: -------------------------------------------------------------------------------- 1 | require "roda" 2 | 3 | require "./routes/albums" 4 | require "./routes/direct_upload" 5 | require "./uploaders/image_uploader" 6 | 7 | class ShrineDemo < Roda 8 | plugin :public 9 | plugin :assets, css: "app.css", js: "app.js" 10 | plugin :run_handler 11 | 12 | use Rack::Session::Cookie, secret: "secret" 13 | plugin :route_csrf, check_header: true 14 | 15 | route do |r| 16 | r.public # serve static assets 17 | r.assets # serve dynamic assets 18 | 19 | check_csrf! 20 | 21 | # redirect '/' to '/albums' 22 | r.root do 23 | r.redirect "/albums", 301 24 | end 25 | 26 | # all '/albums' 27 | r.on "albums" do 28 | r.run Routes::Albums 29 | end 30 | 31 | r.on "derivations/image" do 32 | r.run ImageUploader.derivation_endpoint 33 | end 34 | 35 | # all other routes 36 | r.run Routes::DirectUpload, not_found: :pass 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /demo/assets/css/app.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 800px; 3 | } 4 | 5 | .file-upload-preview[src=""] { 6 | visibility: hidden; 7 | } 8 | -------------------------------------------------------------------------------- /demo/assets/js/app.js: -------------------------------------------------------------------------------- 1 | const singleFileUpload = (fileInput) => { 2 | const imagePreview = document.getElementById(fileInput.dataset.previewElement) 3 | const formGroup = fileInput.parentNode 4 | 5 | formGroup.removeChild(fileInput) 6 | 7 | const uppy = fileUpload(fileInput) 8 | 9 | uppy 10 | .use(Uppy.FileInput, { 11 | target: formGroup, 12 | locale: { strings: { chooseFiles: 'Choose file' } }, 13 | }) 14 | .use(Uppy.Informer, { 15 | target: formGroup, 16 | }) 17 | .use(Uppy.ProgressBar, { 18 | target: imagePreview.parentNode, 19 | }) 20 | .use(Uppy.ThumbnailGenerator, { 21 | thumbnailWidth: 600, 22 | }) 23 | 24 | uppy.on('upload-success', (file, response) => { 25 | // set hidden field value to the uploaded file data so that it's submitted with the form as the attachment 26 | const hiddenInput = document.getElementById(fileInput.dataset.uploadResultElement) 27 | hiddenInput.value = uploadedFileData(file, response, fileInput) 28 | }) 29 | 30 | uppy.on('thumbnail:generated', (file, preview) => { 31 | imagePreview.src = preview 32 | }) 33 | } 34 | 35 | const multipleFileUpload = (fileInput) => { 36 | var formGroup = fileInput.parentNode 37 | 38 | var uppy = fileUpload(fileInput) 39 | 40 | uppy 41 | .use(Uppy.Dashboard, { 42 | target: formGroup, 43 | inline: true, 44 | height: 300, 45 | replaceTargetContent: true, 46 | }) 47 | 48 | uppy.on('upload-success', (file, response) => { 49 | const hiddenField = document.createElement('input') 50 | 51 | hiddenField.type = 'hidden' 52 | hiddenField.name = 'album[photos_attributes]['+ Math.random().toString(36).substr(2, 9) + '][image]' 53 | hiddenField.value = uploadedFileData(file, response, fileInput) 54 | 55 | document.querySelector('form').appendChild(hiddenField) 56 | }) 57 | } 58 | 59 | const fileUpload = (fileInput) => { 60 | const uppy = new Uppy.Core({ 61 | id: fileInput.id, 62 | autoProceed: true, 63 | restrictions: { 64 | allowedFileTypes: fileInput.accept.split(','), 65 | }, 66 | }) 67 | 68 | if (fileInput.dataset.uploadServer == 's3') { 69 | uppy.use(Uppy.AwsS3, { 70 | companionUrl: '/', // will call Shrine's presign endpoint mounted on `/s3/params` 71 | }) 72 | } else { 73 | uppy.use(Uppy.XHRUpload, { 74 | endpoint: '/upload', // Shrine's upload endpoint 75 | headers: { 'X-CSRF-Token': fileInput.dataset.uploadCsrfToken } 76 | }) 77 | } 78 | 79 | return uppy 80 | } 81 | 82 | const uploadedFileData = (file, response, fileInput) => { 83 | if (fileInput.dataset.uploadServer == 's3') { 84 | // construct uploaded file data in the format that Shrine expects 85 | return JSON.stringify({ 86 | id: file.meta['key'].match(/^cache\/(.+)/)[1], // object key without prefix 87 | storage: 'cache', 88 | metadata: { 89 | size: file.size, 90 | filename: file.name, 91 | mime_type: file.type, 92 | } 93 | }) 94 | } else { 95 | return JSON.stringify(response.body) 96 | } 97 | } 98 | 99 | document.querySelectorAll('input[type=file]').forEach((fileInput) => { 100 | if (fileInput.multiple) { 101 | multipleFileUpload(fileInput) 102 | } else { 103 | singleFileUpload(fileInput) 104 | } 105 | }) 106 | -------------------------------------------------------------------------------- /demo/config.ru: -------------------------------------------------------------------------------- 1 | require "./app" 2 | 3 | run ShrineDemo 4 | -------------------------------------------------------------------------------- /demo/config/credentials.rb: -------------------------------------------------------------------------------- 1 | require "dotenv" 2 | 3 | Dotenv.load! 4 | -------------------------------------------------------------------------------- /demo/config/sequel.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | 3 | DB = Sequel.sqlite("database.sqlite3") 4 | 5 | Sequel::Model.plugin :nested_attributes 6 | Sequel::Model.plugin :association_dependencies 7 | Sequel::Model.plugin :validation_helpers 8 | 9 | Sequel::Model.plugin :forme 10 | -------------------------------------------------------------------------------- /demo/config/shrine.rb: -------------------------------------------------------------------------------- 1 | # This is a general base configuration for Shrine in the app. 2 | # It's typically placed in a `config` and/or `initializers` folder. 3 | 4 | require "./config/credentials" 5 | require "shrine" 6 | require "dry-monitor" 7 | 8 | # needed by `backgrounding` plugin 9 | require "./jobs/attachment/promote_job" 10 | require "./jobs/attachment/destroy_job" 11 | 12 | # use S3 for production and local file for other environments 13 | if ENV["RACK_ENV"] == "production" 14 | require "shrine/storage/s3" 15 | 16 | s3_options = { 17 | bucket: ENV.fetch("S3_BUCKET"), 18 | region: ENV.fetch("S3_REGION"), 19 | access_key_id: ENV.fetch("S3_ACCESS_KEY_ID"), 20 | secret_access_key: ENV.fetch("S3_SECRET_ACCESS_KEY"), 21 | } 22 | 23 | # both `cache` and `store` storages are needed 24 | Shrine.storages = { 25 | cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options), 26 | store: Shrine::Storage::S3.new(**s3_options), 27 | } 28 | else 29 | require "shrine/storage/file_system" 30 | 31 | # both `cache` and `store` storages are needed 32 | Shrine.storages = { 33 | cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"), 34 | store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"), 35 | } 36 | end 37 | 38 | Shrine.plugin :sequel 39 | Shrine.plugin :instrumentation, notifications: Dry::Monitor::Notifications.new(:test) 40 | Shrine.plugin :determine_mime_type, analyzer: :marcel, log_subscriber: nil 41 | Shrine.plugin :cached_attachment_data 42 | Shrine.plugin :restore_cached_data 43 | Shrine.plugin :derivatives # eager processing 44 | Shrine.plugin :derivation_endpoint, # on-the-fly processing 45 | secret_key: "secret" 46 | 47 | if ENV["RACK_ENV"] == "production" 48 | Shrine.plugin :presign_endpoint, presign_options: -> (request) { 49 | # Uppy will send the "filename" and "type" query parameters 50 | filename = request.params["filename"] 51 | type = request.params["type"] 52 | 53 | { 54 | content_disposition: ContentDisposition.inline(filename), # set download filename 55 | content_type: type, # set content type 56 | content_length_range: 0..(10*1024*1024), # limit upload size to 10 MB 57 | } 58 | } 59 | else 60 | Shrine.plugin :upload_endpoint 61 | end 62 | 63 | # delay promoting and deleting files to a background job (`backgrounding` plugin) 64 | Shrine.plugin :backgrounding 65 | 66 | Shrine::Attacher.promote_block do 67 | Attachment::PromoteJob.perform_async( 68 | self.class.name, 69 | record.class.name, 70 | record.id, 71 | name, 72 | file_data, 73 | ) 74 | end 75 | 76 | Shrine::Attacher.destroy_block do 77 | Attachment::DestroyJob.perform_async( 78 | self.class.name, 79 | data, 80 | ) 81 | end 82 | -------------------------------------------------------------------------------- /demo/db/migrations/001_create_albums.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:albums) do 4 | primary_key :id 5 | 6 | String :name 7 | String :cover_photo_data # Shrine will store the file info here for the album's cover_photo 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /demo/db/migrations/002_create_photos.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:photos) do 4 | primary_key :id 5 | foreign_key :album_id, :albums 6 | 7 | String :title 8 | String :image_data # Shrine will store the file info here for the photo's image 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /demo/jobs/attachment/destroy_job.rb: -------------------------------------------------------------------------------- 1 | require "sucker_punch" 2 | 3 | module Attachment 4 | class DestroyJob 5 | include SuckerPunch::Job 6 | 7 | def perform(attacher_class, data) 8 | attacher_class = Object.const_get(attacher_class) 9 | 10 | attacher = attacher_class.from_data(data) 11 | attacher.destroy 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /demo/jobs/attachment/promote_job.rb: -------------------------------------------------------------------------------- 1 | require "sucker_punch" 2 | 3 | module Attachment 4 | class PromoteJob 5 | include SuckerPunch::Job 6 | 7 | def perform(attacher_class, record_class, record_id, name, file_data) 8 | attacher_class = Object.const_get(attacher_class) 9 | record = Object.const_get(record_class).with_pk!(record_id) 10 | 11 | attacher = attacher_class.retrieve(model: record, name: name, file: file_data) 12 | attacher.create_derivatives if record.is_a?(Album) 13 | attacher.atomic_promote 14 | rescue Shrine::AttachmentChanged 15 | # attachment has changed, so nothing to do 16 | rescue Sequel::NoMatchingRow, Sequel::NoExistingObject 17 | # record has been deleted, so nothing to do 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /demo/lib/generate_thumbnail.rb: -------------------------------------------------------------------------------- 1 | require "image_processing/mini_magick" 2 | 3 | class GenerateThumbnail 4 | def self.call(file, width, height) 5 | magick = ImageProcessing::MiniMagick.source(file) 6 | magick.resize_to_limit!(width, height) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /demo/models/album.rb: -------------------------------------------------------------------------------- 1 | require "./config/sequel" 2 | require "./uploaders/image_uploader" 3 | 4 | class Album < Sequel::Model 5 | one_to_many :photos 6 | nested_attributes :photos, destroy: true 7 | add_association_dependencies photos: :destroy 8 | 9 | include ImageUploader::Attachment(:cover_photo) # ImageUploader will attach and manage `cover_photo` 10 | 11 | def validate 12 | super 13 | validates_presence [:name, :cover_photo] # Normal model validations - optional 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /demo/models/photo.rb: -------------------------------------------------------------------------------- 1 | require "./config/sequel" 2 | require "./uploaders/image_uploader" 3 | 4 | class Photo < Sequel::Model 5 | include ImageUploader::Attachment(:image) # ImageUploader will attach and manage `image` 6 | end 7 | -------------------------------------------------------------------------------- /demo/routes/albums.rb: -------------------------------------------------------------------------------- 1 | require "./routes/base" 2 | require "./models/album" 3 | require "./models/photo" 4 | 5 | module Routes 6 | class Albums < Base 7 | route do |r| 8 | # '/albums' 9 | r.is do 10 | # GET '/albums' 11 | r.get do 12 | albums = Album.all 13 | view("albums/index", locals: { albums: albums }) 14 | end 15 | 16 | # POST '/albums' 17 | r.post do 18 | album = Album.new(params[:album]) 19 | 20 | if album.valid? 21 | album.save 22 | r.redirect album_path(album) 23 | else 24 | view("albums/new", locals: { album: album }) 25 | end 26 | end 27 | end 28 | 29 | # GET '/albums/new' 30 | r.get "new" do 31 | album = Album.new 32 | view("albums/new", locals: { album: album }) 33 | end 34 | 35 | # '/albums/:id' 36 | r.is Integer do |album_id| 37 | album = Album[album_id] or not_found! 38 | 39 | # GET '/albums/:id' 40 | r.get do 41 | view("albums/show", locals: { album: album }) 42 | end 43 | 44 | # PUT '/albums/:id' 45 | r.put do 46 | album.set(params[:album]) 47 | 48 | if album.valid? 49 | album.save 50 | r.redirect album_path(album) 51 | else 52 | view("albums/show", locals: { album: album }) 53 | end 54 | end 55 | 56 | # DELETE '/albums/:id' 57 | r.delete do 58 | album.destroy 59 | r.redirect albums_path 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /demo/routes/base.rb: -------------------------------------------------------------------------------- 1 | require "roda" 2 | 3 | # Base Roda route configuration 4 | module Routes 5 | class Base < Roda 6 | plugin :environments 7 | 8 | plugin :render 9 | plugin :forme_route_csrf 10 | plugin :partials 11 | plugin :assets, css: "app.css", js: "app.js" 12 | 13 | use Rack::MethodOverride 14 | plugin :all_verbs 15 | 16 | plugin :indifferent_params 17 | plugin :path 18 | 19 | path(:albums, "/albums") 20 | path(:new_album, "/albums/new") 21 | path(:album) { |album| "/albums/#{album.id}" } 22 | 23 | def not_found! 24 | response.status = 404 25 | request.halt 26 | end 27 | 28 | def upload_server 29 | if self.class.production? 30 | :s3 31 | else 32 | :app 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /demo/routes/direct_upload.rb: -------------------------------------------------------------------------------- 1 | require "./routes/base" 2 | require "./config/shrine" 3 | 4 | module Routes 5 | class DirectUpload < Base 6 | if production? 7 | route do |r| 8 | # Only '/s3/params' 9 | r.is "s3/params" do 10 | # GET /s3/params 11 | r.run Shrine.presign_endpoint(:cache) 12 | end 13 | end 14 | else 15 | # In development and test environment we're using filesystem storage 16 | # for speed, so on the client side we'll upload files to our app. 17 | route do |r| 18 | # Only '/upload' 19 | r.is "upload" do 20 | # POST /upload 21 | r.run Shrine.upload_endpoint(:cache) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /demo/test/acceptance_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "Album form" do 4 | it "handles album cover photo upload" do 5 | visit "/" 6 | click_on "New Album" 7 | fill_in "Name", with: "My Album" 8 | find(".uppy-FileInput-input").set(fixture("image.jpg")) 9 | assert_no_selector "#album-cover-photo-upload-result[value=\"\"]" 10 | uploaded_file_data = find("#album-cover-photo-upload-result").value 11 | assert_equal %w[id storage metadata], JSON.parse(uploaded_file_data).keys 12 | assert_no_selector "#preview-cover-photo[value=\"\"]" 13 | preview_url = find("#preview-cover-photo")[:src] 14 | refute_empty preview_url 15 | 16 | click_on "Save" 17 | assert_no_selector ".validation-errors" 18 | preview_url = find("#preview-cover-photo")[:src] 19 | refute_empty preview_url 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /demo/test/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrinerb/shrine/1ce6da4be0ca62ecd5776f107fcd0d657b773f28/demo/test/fixtures/image.jpg -------------------------------------------------------------------------------- /demo/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | ENV["MT_NO_EXPECTATIONS"] = "1" # disable Minitest's expectations monkey-patches 4 | 5 | require "minitest/autorun" 6 | require "minitest/pride" 7 | 8 | require "capybara" 9 | require "capybara/dsl" 10 | require "capybara/minitest" 11 | require "capybara/cuprite" 12 | 13 | require "./app" 14 | require "sucker_punch/testing/inline" 15 | 16 | Capybara.register_driver :cuprite do |app| 17 | Capybara::Cuprite::Driver.new(app, window_size: [1200, 800]) 18 | end 19 | 20 | Capybara.default_driver = :cuprite 21 | Capybara.app = ShrineDemo 22 | Capybara.ignore_hidden_elements = false 23 | 24 | class Minitest::Test 25 | include Capybara::DSL 26 | include Capybara::Minitest::Assertions 27 | 28 | def teardown 29 | Capybara.reset_sessions! 30 | DB[:photos].truncate 31 | DB[:albums].truncate 32 | end 33 | 34 | def fixture(filename) 35 | File.expand_path("test/fixtures/#{filename}") 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /demo/uploaders/image_uploader.rb: -------------------------------------------------------------------------------- 1 | # This is a subclass of Shrine base that will be further configured for it's requirements. 2 | # This will be included in the model to manage the file. 3 | 4 | require "./config/shrine" 5 | require "./lib/generate_thumbnail" 6 | 7 | class ImageUploader < Shrine 8 | ALLOWED_TYPES = %w[image/jpeg image/png image/webp] 9 | MAX_SIZE = 10*1024*1024 # 10 MB 10 | MAX_DIMENSIONS = [5000, 5000] # 5000x5000 11 | 12 | THUMBNAILS = { 13 | small: [300, 300], 14 | medium: [600, 600], 15 | large: [800, 800], 16 | } 17 | 18 | plugin :remove_attachment 19 | plugin :pretty_location 20 | plugin :validation_helpers 21 | plugin :store_dimensions, log_subscriber: nil 22 | plugin :derivation_endpoint, prefix: "derivations/image" 23 | 24 | # File validations (requires `validation_helpers` plugin) 25 | Attacher.validate do 26 | validate_size 0..MAX_SIZE 27 | 28 | if validate_mime_type ALLOWED_TYPES 29 | validate_max_dimensions MAX_DIMENSIONS 30 | end 31 | end 32 | 33 | # Thumbnails processor (requires `derivatives` plugin) 34 | Attacher.derivatives do |original| 35 | THUMBNAILS.transform_values do |(width, height)| 36 | GenerateThumbnail.call(original, width, height) 37 | end 38 | end 39 | 40 | # Default to dynamic thumbnail URL (requires `default_url` plugin) 41 | Attacher.default_url do |derivative: nil, **| 42 | file&.derivation_url(:thumbnail, *THUMBNAILS.fetch(derivative)) if derivative 43 | end 44 | 45 | # Dynamic thumbnail definition (requires `derivation_endpoint` plugin) 46 | derivation :thumbnail do |file, width, height| 47 | GenerateThumbnail.call(file, width.to_i, height.to_i) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /demo/views/albums/_form.erb: -------------------------------------------------------------------------------- 1 | <% form album, { action: form_action, enctype: "multipart/form-data" }, labeler: :explicit do |f| %> 2 | <%= f.input :hidden, name: "_method", value: form_method, obj: nil %> <%# # used in tandem with Rack::MethodOverride to enable PUT requests %> 3 | 4 |
5 | <%= f.input :name, class: "form-control", label_attr: { class: "form-label" } %> 6 |
7 | 8 |
9 | 10 | <%= f.input :cover_photo, 11 | type: :file, 12 | attr: { 13 | accept: ImageUploader::ALLOWED_TYPES.join(",") 14 | }, 15 | label_attr: { 16 | class: "form-label" 17 | }, 18 | data: { 19 | upload_server: upload_server, 20 | upload_csrf_token: csrf_token("/upload"), 21 | preview_element: "preview-cover-photo", 22 | upload_result_element: "album-cover-photo-upload-result" 23 | }, 24 | dasherize_data: true %> 25 | 26 | 27 | <%= f.input :cover_photo, 28 | type: :hidden, 29 | error_handler: false, 30 | value: album.cached_cover_photo_data, 31 | id: "album-cover-photo-upload-result" %> 32 |
33 | 34 |
35 | 36 | 40 |
41 | 42 | <% unless album.new? %> 43 |
44 | 45 | <%= f.input :photos, 46 | type: :file, 47 | label: "Select photos", 48 | attr: { 49 | multiple: true, 50 | accept: ImageUploader::ALLOWED_TYPES.join(",") 51 | }, 52 | label_attr: { 53 | class: "form-label" 54 | }, 55 | data: { 56 | upload_server: upload_server, 57 | upload_csrf_token: csrf_token("/upload") 58 | }, 59 | dasherize_data: true %> 60 |
61 | <% end %> 62 | 63 | 68 | 69 | 70 | ← Back to albums 71 | <% end %> 72 | -------------------------------------------------------------------------------- /demo/views/albums/_photo.erb: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 |
    4 | 5 | 6 |
    7 |
    8 |
    9 | <%= f.input :title, class: "form-control", label_attr: { class: "form-label" } %> 10 |
    11 |
    12 | <%= f.input :_delete, type: :checkbox, class: "form-check-input", label: "Remove photo", label_attr: { class: "form-check-label" } %> 13 |
    14 |
    15 |
    16 |
  • 17 | -------------------------------------------------------------------------------- /demo/views/albums/index.erb: -------------------------------------------------------------------------------- 1 |

    Albums

    2 | 3 |
    + New Album
    4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | <% albums.each do |album| %> 15 | 16 | 17 | 18 | 19 | 20 | <% end %> 21 | 22 |
    CoverNameActions
    <%= album.name %>Edit
    23 | -------------------------------------------------------------------------------- /demo/views/albums/new.erb: -------------------------------------------------------------------------------- 1 |

    New album

    2 | 3 | <%= partial("albums/form", locals: { album: album, form_action: albums_path, form_method: "post" }) %> 4 | -------------------------------------------------------------------------------- /demo/views/albums/show.erb: -------------------------------------------------------------------------------- 1 |

    <%= album.name %>

    2 | 3 | <%= partial("albums/form", locals: { album: album, form_action: album_path(album), form_method: "put" }) %> 4 | -------------------------------------------------------------------------------- /demo/views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Shrine Demo 5 | 6 | 7 | 8 | 9 | 10 | <%= assets(:css) %> 11 | 12 | 13 | 14 |
    15 | <%= yield %> 16 |
    17 | 18 | 19 | <%= assets(:js) %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /doc/changing_storage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: changing-storage 3 | title: Migrating File Storage 4 | --- 5 | 6 | This guides shows how to move file attachments to a different storage in 7 | production, with zero downtime. 8 | 9 | Let's assume we have a `Photo` model with an `image` file attachment stored 10 | in AWS S3 storage: 11 | 12 | ```rb 13 | Shrine.storages = { 14 | cache: Shrine::Storage::S3.new(...), 15 | store: Shrine::Storage::S3.new(...), 16 | } 17 | 18 | Shrine.plugin :activerecord 19 | ``` 20 | ```rb 21 | class ImageUploader < Shrine 22 | # ... 23 | end 24 | ``` 25 | ```rb 26 | class Photo < ActiveRecord::Base 27 | include ImageUploader::Attachment(:image) 28 | end 29 | ``` 30 | 31 | Let's also assume that we're migrating from AWS S3 to Google Cloud Storage, and 32 | we've added the new storage to `Shrine.storages`: 33 | 34 | ```rb 35 | Shrine.storages = { 36 | ... 37 | store: Shrine::Storage::S3.new(...), 38 | gcs: Shrine::Storage::GoogleCloudStorage.new(...), 39 | } 40 | ``` 41 | 42 | ## 1. Mirror upload and delete operations 43 | 44 | The first step is to start mirroring uploads and deletes made on your current 45 | storage to the new storage. We can do this by loading the `mirroring` plugin: 46 | 47 | ```rb 48 | Shrine.plugin :mirroring, mirror: { store: :gcs } 49 | ``` 50 | 51 | Put the above code in an initializer and deploy it. 52 | 53 | You can additionally delay the mirroring into a [background job][mirroring 54 | backgrounding] for better performance. 55 | 56 | ## 2. Copy the files 57 | 58 | Next step is to copy all remaining files from current storage into the new 59 | storage using the following script. It fetches the photos in batches, downloads 60 | the image, and re-uploads it to the new storage. 61 | 62 | ```rb 63 | Photo.find_each do |photo| 64 | attacher = photo.image_attacher 65 | 66 | next unless attacher.stored? 67 | 68 | attacher.file.trigger_mirror_upload 69 | 70 | # if using derivatives 71 | attacher.map_derivative(attacher.derivatives) do |_, derivative| 72 | derivative.trigger_mirror_upload 73 | end 74 | end 75 | ``` 76 | 77 | Now the new storage should have all files the current storage has, and new 78 | uploads will continue being mirrored to the new storage. 79 | 80 | ## 3. Update storage 81 | 82 | Once all the files are copied over to the new storage, everything should be 83 | ready for us to update the storage in the Shrine configuration. We can keep 84 | mirroring, in case the change would need to reverted. 85 | 86 | ```rb 87 | Shrine.storages = { 88 | ... 89 | store: Shrine::Storage::GoogleCloudStorage.new(...), 90 | s3: Shrine::Storage::S3.new(...), 91 | } 92 | 93 | Shrine.plugin :mirroring, mirror: { store: :s3 } # mirror to :s3 storage 94 | ``` 95 | 96 | ## 4. Remove mirroring 97 | 98 | Once everything is looking good, we can remove the mirroring: 99 | 100 | ```diff 101 | Shrine.storages = { 102 | ... 103 | store: Shrine::Storage::GoogleCloudStorage.new(...), 104 | - s3: Shrine::Storage::S3.new(...), 105 | } 106 | 107 | - Shrine.plugin :mirroring, mirror: { store: :s3 } # mirror to :s3 storage 108 | ``` 109 | 110 | [mirroring backgrounding]: https://shrinerb.com/docs/plugins/mirroring#backgrounding 111 | -------------------------------------------------------------------------------- /doc/creating_plugins.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: creating-plugins 3 | title: Writing a Plugin 4 | --- 5 | 6 | Shrine has a lot of plugins built-in, but you can use Shrine's plugin system to 7 | create your own. 8 | 9 | ## Definition 10 | 11 | Simply put, a plugin is a module: 12 | 13 | ```rb 14 | module MyPlugin 15 | # ... 16 | end 17 | 18 | Shrine.plugin MyPlugin 19 | ``` 20 | 21 | If you would like to load plugins with a symbol (like you already do with 22 | plugins that ship with Shrine), you need to put the plugin in 23 | `shrine/plugins/my_plugin.rb` in your load path and register it: 24 | 25 | ```rb 26 | # shrine/plugins/my_plugin.rb 27 | class Shrine 28 | module Plugins 29 | module MyPlugin 30 | # ... 31 | end 32 | 33 | register_plugin(:my_plugin, MyPlugin) 34 | end 35 | end 36 | ``` 37 | ```rb 38 | Shrine.plugin :my_plugin 39 | ``` 40 | 41 | ## Methods 42 | 43 | The way to make plugins actually extend Shrine's core classes is by defining 44 | special modules inside the plugin. Here's a list of all "special" modules: 45 | 46 | ```rb 47 | InstanceMethods # gets included into `Shrine` 48 | ClassMethods # gets extended into `Shrine` 49 | AttachmentMethods # gets included into `Shrine::Attachment` 50 | AttachmentClassMethods # gets extended into `Shrine::Attachment` 51 | AttacherMethods # gets included into `Shrine::Attacher` 52 | AttacherClassMethods # gets extended into `Shrine::Attacher` 53 | FileMethods # gets included into `Shrine::UploadedFile` 54 | FileClassMethods # gets extended into `Shrine::UploadedFile` 55 | ``` 56 | 57 | For example, this is how you would make your plugin add some logging to 58 | uploading: 59 | 60 | ```rb 61 | module MyPlugin 62 | module InstanceMethods 63 | def upload(io, **options) 64 | time = Time.now 65 | result = super 66 | duration = Time.now - time 67 | puts "Upload duration: #{duration}s" 68 | end 69 | end 70 | end 71 | ``` 72 | 73 | Notice that we can call `super` to get the original behaviour. 74 | 75 | ## Configuration 76 | 77 | You'll likely want to make your plugin configurable. You can do that by 78 | overriding the `.configure` class method and storing received options into 79 | `Shrine.opts`: 80 | 81 | ```rb 82 | module MyPlugin 83 | def self.configure(uploader, **opts) 84 | uploader.opts[:my_plugin] ||= {} 85 | uploader.opts[:my_plugin].merge!(opts) 86 | end 87 | 88 | module InstanceMethods 89 | def upload(io, **options) 90 | opts[:my_plugin] #=> { ... } 91 | # ... 92 | end 93 | end 94 | end 95 | ``` 96 | 97 | Users can now pass these configuration options when loading your plugin: 98 | 99 | ```rb 100 | Shrine.plugin :my_plugin, foo: "bar" 101 | ``` 102 | 103 | ## Dependencies 104 | 105 | If your plugin depends on other plugins, you can load them inside of 106 | `.load_dependencies`: 107 | 108 | ```rb 109 | module MyPlugin 110 | def self.load_dependencies(uploader, **opts) 111 | uploader.plugin :derivatives # depends on the derivatives plugin 112 | end 113 | end 114 | ``` 115 | 116 | The dependencies will get loaded before your plugin, allowing you to override 117 | methods of your dependencies in your method modules. 118 | 119 | The same configuration options passed to `.configure` are passed to 120 | `.load_dependencies` as well. 121 | -------------------------------------------------------------------------------- /doc/external/misc.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Miscellaneous 3 | --- 4 | 5 | ## Demos 6 | 7 | | Demo | Description | 8 | | :--- | :---------- | 9 | | [Dropzone demo](https://github.com/codyeatworld/example-shrine-dropzone) | Shows direct upload using [Dropzone.js] | 10 | | [Hanami demo](https://github.com/katafrakt/hanami-shrine-example) | Shows file attachment in [Hanami] | 11 | | [Crop demo](https://github.com/shrinerb/shrine-crop-example) | Shows image cropping using [Cropper.js] | 12 | | [Rails demo](https://github.com/erikdahlstrand/shrine-rails-example) | Shows direct upload in [Rails] | 13 | | [Resumable uploads demo](https://github.com/shrinerb/shrine-tus-demo) | Shows resumable direct upload on [tus] | 14 | | [Roda demo (official)](https://github.com/shrinerb/shrine/tree/master/demo) | Shows direct upload in [Roda] | 15 | | [rom-rb & dry-rb demo](https://github.com/shrinerb/shrine-rom/tree/master/demo) | Shows file attachment with [rom-rb] and [dry-rb] | 16 | | [Transloadit demo](https://github.com/shrinerb/shrine-transloadit/tree/master/demo) | Shows file processing using [Transloadit] | 17 | 18 | ## Projects 19 | 20 | | Project | Description | 21 | | :------ | :---------- | 22 | | [CortexCMS](https://docs.cortexcms.org) | An open source, enterprise content management and distribution platform | 23 | 24 | [Dropzone.js]: https://www.dropzonejs.com/ 25 | [Hanami]: https://hanamirb.org/ 26 | [Cropper.js]: https://github.com/fengyuanchen/cropperjs 27 | [Rails]: https://rubyonrails.org/ 28 | [tus]: https://tus.io/ 29 | [Roda]: https://roda.jeremyevans.net/ 30 | [rom-rb]: https://rom-rb.org/ 31 | [dry-rb]: https://dry-rb.org/ 32 | [Transloadit]: https://transloadit.com/ 33 | -------------------------------------------------------------------------------- /doc/plugins/cached_attachment_data.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cached Attachment Data 3 | --- 4 | 5 | The [`cached_attachment_data`][cached_attachment_data] plugin adds the ability 6 | to retain the cached file across form redisplays, which means the file doesn't 7 | have to be reuploaded in case of validation errors. 8 | 9 | ```rb 10 | plugin :cached_attachment_data 11 | ``` 12 | 13 | The plugin adds `#cached__data` to the model, which returns the 14 | cached file as JSON, and should be used to set the value of the hidden form 15 | field. 16 | 17 | ```rb 18 | photo.cached_image_data #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}' 19 | ``` 20 | 21 | This method delegates to `Attacher#cached_data`: 22 | 23 | ```rb 24 | attacher.cached_data #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}' 25 | ``` 26 | 27 | [cached_attachment_data]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/cached_attachment_data.rb 28 | -------------------------------------------------------------------------------- /doc/plugins/default_storage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Default Storage 3 | --- 4 | 5 | The [`default_storage`][default_storage] plugin allows you to change the 6 | default temporary and permanent storage a `Shrine::Attacher` object will use 7 | (the default is `:cache` and `:store`). 8 | 9 | ```rb 10 | plugin :default_storage, cache: :other_cache, store: :other_store 11 | ``` 12 | 13 | If you want the storage to be dynamic based on `Attacher` data, you can use a 14 | block, and it will be evaluated in context of the `Attacher` instance: 15 | 16 | ```rb 17 | plugin :default_storage, store: -> { 18 | if record.is_a?(Photo) 19 | :photo_store 20 | else 21 | :store 22 | end 23 | } 24 | ``` 25 | 26 | You can also set default storage with `Attacher#default_cache` and 27 | `Attacher#default_store`: 28 | 29 | ```rb 30 | # default temporary storage 31 | Attacher.default_cache :other_cache 32 | # or 33 | Attacher.default_cache { :other_cache } 34 | 35 | # default permanent storage 36 | Attacher.default_store :other_store 37 | # or 38 | Attacher.default_store { :other_store } 39 | ``` 40 | 41 | The dynamic block is useful in combination with the 42 | [`dynamic_storage`][dynamic_storage] plugin. 43 | 44 | [default_storage]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/default_storage.rb 45 | [dynamic_storage]: https://shrinerb.com/docs/plugins/dynamic_storage 46 | -------------------------------------------------------------------------------- /doc/plugins/default_url.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Default URL 3 | --- 4 | 5 | The [`default_url`][default_url] plugin allows setting the URL which will be 6 | returned when there is no attached file. 7 | 8 | ```rb 9 | plugin :default_url 10 | 11 | Attacher.default_url do |**options| 12 | "/#{name}/missing.jpg" 13 | end 14 | ``` 15 | 16 | The `Attacher#url` method will return the default URL when attachment is 17 | missing: 18 | 19 | ```rb 20 | user.avatar_url #=> "/avatar/missing.jpg" 21 | # or 22 | attacher.url #=> "/avatar/missing.jpg" 23 | ``` 24 | 25 | Any URL options passed will be available in the default URL block: 26 | 27 | ```rb 28 | attacher.url(foo: "bar") 29 | ``` 30 | ```rb 31 | Attacher.default_url do |**options| 32 | options #=> { foo: "bar" } 33 | end 34 | ``` 35 | 36 | The default URL block is evaluated in the context of an instance of 37 | `Shrine::Attacher`. 38 | 39 | ```rb 40 | Attacher.default_url do |**options| 41 | self #=> # 42 | 43 | file #=> # 44 | name #=> :avatar 45 | record #=> # 46 | context #=> { ... } 47 | 48 | # ... 49 | end 50 | ``` 51 | 52 | ## Host 53 | 54 | If the default URL is relative, the URL host can be specified via the `:host` 55 | option: 56 | 57 | ```rb 58 | plugin :default_url, host: "https://example.com" 59 | ``` 60 | ```rb 61 | attacher.url #=> "https://example.com/avatar/missing.jpg" 62 | ``` 63 | 64 | [default_url]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/default_url.rb 65 | -------------------------------------------------------------------------------- /doc/plugins/delete_raw.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Delete Raw 3 | --- 4 | 5 | The [`delete_raw`][delete_raw] plugin will automatically delete raw files that 6 | have been uploaded. This is especially useful when doing processing, to ensure 7 | that temporary files have been deleted after upload. 8 | 9 | ```rb 10 | plugin :delete_raw 11 | ``` 12 | 13 | By default any raw file that was uploaded will be deleted, but you can limit 14 | this only to files uploaded to certain storages: 15 | 16 | ```rb 17 | plugin :delete_raw, storages: [:store] 18 | ``` 19 | 20 | If you want to skip deletion for a certain upload, you can pass `delete: false` 21 | to the uploader: 22 | 23 | ```rb 24 | uploader.upload(file, delete: false) 25 | ``` 26 | 27 | [delete_raw]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/delete_raw.rb 28 | -------------------------------------------------------------------------------- /doc/plugins/dynamic_storage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dynamic Storage 3 | --- 4 | 5 | The [`dynamic_storage`][dynamic_storage] plugin allows you to register a 6 | storage using a regex, and evaluate the storage class dynamically depending on 7 | the regex. 8 | 9 | Example: 10 | 11 | ```rb 12 | plugin :dynamic_storage 13 | 14 | storage /store_(\w+)/ do |match| 15 | Shrine::Storage::S3.new(bucket: match[1]) 16 | end 17 | ``` 18 | 19 | The above example uses S3 storage where the bucket name depends on the storage 20 | name suffix. For example, `:store_foo` will use S3 storage which saves files to 21 | the bucket "foo". The block is yielded an instance of `MatchData`. 22 | 23 | This can be useful in combination with the `default_storage` plugin. 24 | 25 | [dynamic_storage]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/dynamic_storage.rb 26 | -------------------------------------------------------------------------------- /doc/plugins/form_assign.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Form Assign 3 | --- 4 | 5 | The [`form_assign`][form_assign] plugin allows attaching file from form params 6 | without a form object. 7 | 8 | ```rb 9 | plugin :form_assign 10 | ``` 11 | 12 | The `Attacher#form_assign` method will detect the file param and assign it to 13 | the attacher: 14 | 15 | ```rb 16 | attacher = photo.image_attacher 17 | attacher.form_assign({ "image" => file, "title" => "...", "description" => "..." }) 18 | attacher.file #=> # 19 | ``` 20 | 21 | It works with `remote_url`, `data_uri`, and `remove_attachment` plugins: 22 | 23 | ```rb 24 | # remote_url plugin 25 | attacher.form_assign({ "image_remote_url" => "https://example.com/..." }) 26 | attacher.file #=> # 27 | ``` 28 | ```rb 29 | # data_uri plugin 30 | attacher.form_assign({ "image_data_uri" => "data:image/jpeg;base64,..." }) 31 | attacher.file #=> # 32 | ``` 33 | ```rb 34 | # remove_attachment plugin 35 | attacher.form_assign({ "remove_image" => "1" }) 36 | attacher.file #=> nil 37 | ``` 38 | 39 | The return value is a hash with form params, with file param replaced with 40 | cached file data, which can later be assigned again to the record. 41 | 42 | ```rb 43 | attacher.form_assign({ "image" => file, "title" => "...", "description" => "..." }) 44 | #=> { :image => '{"id":"...","storage":"...","metadata":"..."}', "title" => "...", "description" => "..." } 45 | ``` 46 | 47 | You can also have attached file data returned as the `_data` attribute, 48 | suitable for persisting. 49 | 50 | ```rb 51 | attacher.form_assign({ "image" => image, ... }, result: :attributes) 52 | #=> { :image_data => '{"id":"...","storage":"...","metadata":"..."}', "title" => "...", "description" => "..." } 53 | ``` 54 | 55 | [form_assign]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/form_assign.rb 56 | -------------------------------------------------------------------------------- /doc/plugins/included.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Included 3 | --- 4 | 5 | The [`included`][included] plugin allows you to hook up to the `.included` hook 6 | of the attachment module, and call additional methods on the model that 7 | includes it. 8 | 9 | ```rb 10 | class ImageUploader < Shrine 11 | plugin :included do |name| 12 | # called when attachment module is included into a model 13 | 14 | self #=> Photo (the model class) 15 | name #=> :image 16 | end 17 | end 18 | ``` 19 | ```rb 20 | class Photo 21 | include ImageUploader::Attachment(:image) # triggers the included block 22 | end 23 | ``` 24 | 25 | For example, you can use it to define additional methods on the model: 26 | 27 | ```rb 28 | class ImageUploader < Shrine 29 | plugin :included do |name| 30 | define_method(:"#{name}_width") { send(name)&.width } 31 | define_method(:"#{name}_height") { send(name)&.height } 32 | end 33 | end 34 | ``` 35 | ```rb 36 | photo = Photo.new(image: file) 37 | photo.image_width #=> 1200 38 | photo.image_height #=> 800 39 | ``` 40 | 41 | [included]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/included.rb 42 | -------------------------------------------------------------------------------- /doc/plugins/keep_files.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Keep Files 3 | --- 4 | 5 | The [`keep_files`][keep_files] plugin prevents the attached file (and any of 6 | its [derivatives]) from being deleted when the attachment would normally be 7 | destroyed, which happens when the attachment is removed/replaced, or when the 8 | record is deleted. This functionality is useful when implementing soft deletes, 9 | versioning, or in general any scenario where you need to keep history. 10 | 11 | ```rb 12 | plugin :keep_files 13 | ``` 14 | ```rb 15 | photo.image #=> # 16 | photo.destroy 17 | photo.image.exists? #=> true 18 | ``` 19 | 20 | [keep_files]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/keep_files.rb 21 | [derivatives]: https://shrinerb.com/docs/plugins/derivatives 22 | -------------------------------------------------------------------------------- /doc/plugins/metadata_attributes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Metadata Attributes 3 | --- 4 | 5 | The [`metadata_attributes`][metadata_attributes] plugin allows you to write 6 | attachment metadata to additional record attributes. You can configure the 7 | plugin with a hash of mappings: 8 | 9 | ```rb 10 | plugin :metadata_attributes, :size => :size, :mime_type => :type 11 | # or 12 | plugin :metadata_attributes 13 | Attacher.metadata_attributes :size => :size, :mime_type => :type 14 | ``` 15 | 16 | The above configuration will write `size` metadata field to `_size` 17 | record attribute, and `mime_type` metadata field to `_type` record 18 | attribute. 19 | 20 | ```rb 21 | user.avatar = image 22 | user.avatar.metadata["size"] #=> 95724 23 | user.avatar_size #=> 95724 24 | user.avatar.metadata["mime_type"] #=> "image/jpeg" 25 | user.avatar_type #=> "image/jpeg" 26 | 27 | user.avatar = nil 28 | user.avatar_size #=> nil 29 | user.avatar_type #=> nil 30 | ``` 31 | 32 | ## Model and Entity 33 | 34 | With the [`model`][model] plugin, any method that internally calls 35 | `Attacher#write` will trigger metadata attributes writing (`Attacher#assign`, 36 | `Attacher#attach`, `Attacher#change`, `Attacher#set`). 37 | 38 | ```rb 39 | attacher.file.metadata["mime_type"] = "other/type" 40 | attacher.write 41 | attacher.record.avatar_type #=> "other/type" 42 | ``` 43 | 44 | If you're using the [`entity`][entity] plugin, metadata attributes will be 45 | added to `Attacher#column_values`: 46 | 47 | ```rb 48 | attacher.assign(io) 49 | attacher.column_values #=> 50 | # { 51 | # :image_data => '{ ... }', 52 | # :image_size => 95724, 53 | # :image_type => "image/jpeg", 54 | # } 55 | ``` 56 | 57 | Any metadata attributes that were declared but are missing on the record will 58 | be skipped. 59 | 60 | ## Full attribute name 61 | 62 | If you want to specify the full record attribute name, pass the record 63 | attribute name as a string instead of a symbol. 64 | 65 | ```rb 66 | Attacher.metadata_attributes :filename => "original_filename" 67 | ``` 68 | ```rb 69 | photo.image = image 70 | photo.original_filename #=> "nature.jpg" 71 | ``` 72 | 73 | [metadata_attributes]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/metadata_attributes.rb 74 | [entity]: https://shrinerb.com/docs/plugins/entity 75 | [model]: https://shrinerb.com/docs/plugins/model 76 | -------------------------------------------------------------------------------- /doc/plugins/module_include.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Module Include 3 | --- 4 | 5 | The [`module_include`][module_include] plugin allows you to extend Shrine's 6 | core classes for the given uploader with modules/methods. 7 | 8 | ```rb 9 | plugin :module_include 10 | ``` 11 | 12 | To add a module to a core class, call the appropriate method: 13 | 14 | ```rb 15 | attachment_module CustomAttachmentMethods 16 | attacher_module CustomAttacherMethods 17 | file_module CustomFileMethods 18 | ``` 19 | 20 | Alternatively you can pass in a block (which internally creates a module): 21 | 22 | ```rb 23 | attachment_module do 24 | def included(model) 25 | super 26 | 27 | name = attachment_name 28 | 29 | define_method :"#{name}_size" do |version| 30 | attachment = send(name) 31 | if attachment.is_a?(Hash) 32 | attachment[version].size 33 | elsif attachment 34 | attachment.size 35 | end 36 | end 37 | end 38 | end 39 | ``` 40 | 41 | The above defines an additional `#_size` method on the attachment 42 | module, which is what is included in your model. 43 | 44 | [module_include]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/module_include.rb 45 | -------------------------------------------------------------------------------- /doc/plugins/multi_cache.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Multi Cache 3 | --- 4 | 5 | The [`multi_cache`][multi_cache] plugin allows an attacher to accept files from 6 | additional temporary storages. 7 | 8 | ```rb 9 | Shrine.storages = { cache: ..., cache_one: ..., cache_two: ..., store: ... } 10 | 11 | Shrine.plugin :multi_cache, additional_cache: [:cache_one, :cache_two] 12 | ``` 13 | ```rb 14 | photo.image = { "id" => "...", "storage" => "cache", "metadata" => { ... } } 15 | photo.image.storage_key #=> :cache 16 | # or 17 | photo.image = { "id" => "...", "storage" => "cache_one", "metadata" => { ... } } 18 | photo.image.storage_key #=> :cache_one 19 | # or 20 | photo.image = { "id" => "...", "storage" => "cache_two", "metadata" => { ... } } 21 | photo.image.storage_key #=> :cache_two 22 | ``` 23 | 24 | [multi_cache]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/multi_cache.rb 25 | -------------------------------------------------------------------------------- /doc/plugins/pretty_location.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pretty Location 3 | --- 4 | 5 | The [`pretty_location`][pretty_location] plugin attempts to generate a nicer 6 | folder structure for uploaded files. 7 | 8 | ```rb 9 | plugin :pretty_location 10 | ``` 11 | 12 | This plugin uses the context information from the Attacher to try to generate a 13 | nested folder structure which separates files for each record. The newly 14 | generated locations will typically look like this: 15 | 16 | ```rb 17 | "user/564/avatar/thumb-493g82jf23.jpg" 18 | # :model/:id/:attachment/:derivative-:uid.:extension 19 | ``` 20 | 21 | By default if a record class is inside a namespace, only the "inner" class name 22 | is used in the location. If you want to include the namespace, you can pass in 23 | the `:namespace` option with the desired separator as the value: 24 | 25 | ```rb 26 | plugin :pretty_location, namespace: "_" 27 | # "blog_user/.../493g82jf23.jpg" 28 | 29 | plugin :pretty_location, namespace: "/" 30 | # "blog/user/.../493g82jf23.jpg" 31 | ``` 32 | 33 | By default, if there is a record present, the record `id` will is used in the location. 34 | If you want to use a different identifier for the record, you can pass in 35 | the `:identifier` option with the desired method/attribute name as the value: 36 | 37 | ```rb 38 | plugin :pretty_location, identifier: "uuid" 39 | # "user/aa357797-5845-451b-8662-08eecdc9f762/profile_picture/493g82jf23.jpg" 40 | 41 | plugin :pretty_location, identifier: :email 42 | # "user/foo@bar.com/profile_picture/493g82jf23.jpg" 43 | ``` 44 | 45 | By default, the class name will be only downcased. We can also have the class 46 | name underscored with the `:class_underscore` option: 47 | 48 | ```ruby 49 | plugin :pretty_location 50 | # "blogpost/aa357797-5845-451b-8662-08eecdc9f762/image/493g82jf23.jpg" 51 | 52 | plugin :pretty_location, class_underscore: :true 53 | # "blog_post/aa357797-5845-451b-8662-08eecdc9f762/image/493g82jf23.jpg" 54 | ``` 55 | 56 | For a more custom identifier logic, you can overwrite the method 57 | `#generate_location` and call `#pretty_location` with the identifier you have 58 | calculated. 59 | 60 | ```rb 61 | def generate_location(io, record: nil, **context) 62 | identifier = record.email if record.is_a?(User) 63 | pretty_location(io, record: record, identifier: identifier, **context) 64 | end 65 | ``` 66 | 67 | [pretty_location]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/pretty_location.rb 68 | -------------------------------------------------------------------------------- /doc/plugins/processing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Processing 3 | --- 4 | 5 | Shrine uploaders can define the `#process` method, which will get called 6 | whenever a file is uploaded. It is given the original file, and is expected to 7 | return the processed files. 8 | 9 | ```rb 10 | def process(io, context) 11 | # you can process the original file `io` and return processed file(s) 12 | end 13 | ``` 14 | 15 | However, when handling files as attachments, the same file is uploaded to 16 | temporary and permanent storage. Since we only want to apply the same 17 | processing once, we need to branch based on the context. 18 | 19 | ```rb 20 | def process(io, context) 21 | if context[:action] == :store # promote phase 22 | # ... 23 | end 24 | end 25 | ``` 26 | 27 | The [`processing`][processing] plugin simplifies this by allowing us to 28 | declaratively define file processing for specified actions. 29 | 30 | ```rb 31 | plugin :processing 32 | 33 | process(:store) do |io, context| 34 | # ... 35 | end 36 | ``` 37 | 38 | An example of resizing an image using the [image_processing] library: 39 | 40 | ```rb 41 | require "image_processing/mini_magick" 42 | 43 | process(:store) do |io, context| 44 | io.download do |original| 45 | ImageProcessing::MiniMagick 46 | .source(original) 47 | .resize_to_limit!(800, 800) 48 | end 49 | end 50 | ``` 51 | 52 | The declarations are additive and inheritable, so for the same action you can 53 | declare multiple blocks, and they will be performed in the same order, with 54 | output from previous block being the input to next. 55 | 56 | ## Manually Run Processing 57 | 58 | You can manually trigger the defined processing via the uploader by calling 59 | `#upload` or `#process` and setting `:action` to the name of your processing 60 | block: 61 | 62 | ```rb 63 | uploader.upload(file, action: :store) # process and upload 64 | uploader.process(file, action: :store) # only process 65 | ``` 66 | 67 | If you want the result of processing to be multiple files, use the `versions` 68 | plugin. 69 | 70 | [processing]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/processing.rb 71 | [image_processing]: https://github.com/janko/image_processing 72 | -------------------------------------------------------------------------------- /doc/plugins/rack_file.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Rack File 3 | --- 4 | 5 | The [`rack_file`][rack_file] plugin enables uploaders to accept Rack uploaded 6 | file hashes for uploading. 7 | 8 | ```rb 9 | plugin :rack_file 10 | ``` 11 | 12 | ## Usage 13 | 14 | When a file is uploaded to your Rack application using the 15 | `multipart/form-data` parameter encoding, Rack converts the uploaded file to a 16 | hash. 17 | 18 | ```rb 19 | file_hash #=> 20 | # { 21 | # :name => "file", 22 | # :filename => "cats.png", 23 | # :type => "image/png", 24 | # :tempfile => #, 25 | # :head => "Content-Disposition: form-data; ...", 26 | # } 27 | ``` 28 | 29 | Since Shrine only accepts IO objects, you would normally need to fetch the 30 | `:tempfile` object and pass it directly. This plugin enables the attacher to 31 | accept the Rack uploaded file hash directly, which is convenient when doing 32 | mass attribute assignment. 33 | 34 | ```rb 35 | user.avatar = file_hash 36 | # or 37 | attacher.assign(file_hash) 38 | ``` 39 | 40 | ## API 41 | 42 | Internally the Rack uploaded file hash will be converted into an IO object 43 | using `Shrine.rack_file`, which you can also use directly: 44 | 45 | ```rb 46 | # or YourUploader.rack_file(file_hash) 47 | io = Shrine.rack_file(file_hash) 48 | io.original_filename #=> "cats.png" 49 | io.content_type #=> "image/png" 50 | io.size #=> 58342 51 | ``` 52 | 53 | Note that this plugin is not needed in Rails applications, as Rails already 54 | wraps the Rack uploaded file hash into an `ActionDispatch::Http::UploadedFile` 55 | object. 56 | 57 | [rack_file]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/rack_file.rb 58 | -------------------------------------------------------------------------------- /doc/plugins/recache.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Re-cache 3 | --- 4 | 5 | The [`recache`][recache] plugin allows you to process your attachment after 6 | validations succeed, but before the attachment is promoted. This is useful for 7 | example when you want to generate some versions upfront (so the user 8 | immediately sees them) and other versions you want to generate in the promotion 9 | phase in a background job. 10 | 11 | ```rb 12 | plugin :recache 13 | plugin :processing 14 | 15 | process(:recache) do |io, context| 16 | # perform cheap processing 17 | end 18 | 19 | process(:store) do |io, context| 20 | # perform more expensive processing 21 | end 22 | ``` 23 | 24 | Recaching will be automatically triggered in a "before save" callback, but if 25 | you're using the attacher directly, you can call it manually: 26 | 27 | ```rb 28 | attacher.recache if attacher.changed? 29 | ``` 30 | 31 | [recache]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/recache.rb 32 | -------------------------------------------------------------------------------- /doc/plugins/refresh_metadata.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Refresh Metadata 3 | --- 4 | 5 | The [`refresh_metadata`][refresh_metadata] plugin allows you to re-extract 6 | metadata from an uploaded file. 7 | 8 | ```rb 9 | plugin :refresh_metadata 10 | ``` 11 | 12 | It provides `#refresh_metadata!` method, which triggers metadata extraction 13 | (calls `Shrine#extract_metadata`) with the uploaded file opened for reading, 14 | and updates the existing metadata hash with the results. This can be done 15 | on the `Shrine::Attacher` or the `Shrine::UploadedFile` level. 16 | 17 | ## Attacher 18 | 19 | Calling `#refresh_metadata!` on a `Shrine::Attacher` object will re-extract 20 | metadata of the attached file, and when used with a [model], it will write new 21 | file data back into the attachment attribute. 22 | 23 | ```rb 24 | attacher.refresh_metadata! 25 | attacher.file.metadata # re-extracted metadata 26 | attacher.record.file_data #=> '{ ... data with updated metadata ... }' 27 | ``` 28 | 29 | The `Attacher#context` hash will be forwarded to metadata extraction, as well 30 | as any options that you pass in. 31 | 32 | ```rb 33 | # via context 34 | attacher.context[:foo] = "bar" 35 | attacher.refresh_metadata! # passes `{ foo: "bar" }` options to metadata extraction 36 | 37 | # via arguments 38 | attacher.refresh_metadata!(foo: "bar") # passes `{ foo: "bar" }` options to metadata extraction 39 | ``` 40 | 41 | ## Uploaded File 42 | 43 | The `#refresh_metadata!` method can be called on a `Shrine::UploadedFile` object 44 | as well. 45 | 46 | ```rb 47 | uploaded_file.refresh_metadata! 48 | uploaded_file.metadata # re-extracted metadata 49 | ``` 50 | 51 | If the uploaded file is not open, it is opened before and closed after metadata 52 | extraction. For remote storage services this will make an HTTP request. 53 | However, only the portion of the file needed for extracting metadata will be 54 | downloaded. 55 | 56 | If the uploaded file is already open, it is passed to metadata extraction as 57 | is. 58 | 59 | ```rb 60 | uploaded_file.open do 61 | uploaded_file.refresh_metadata! # uses the already opened file 62 | # ... 63 | end 64 | ``` 65 | 66 | Any options passed in will be forwarded to metadata extraction: 67 | 68 | ```rb 69 | uploaded_file.refresh_metadata!(foo: "bar") # passes `{ foo: "bar" }` options to metadata extraction 70 | ``` 71 | 72 | [refresh_metadata]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/refresh_metadata.rb 73 | [model]: https://shrinerb.com/docs/plugins/model 74 | -------------------------------------------------------------------------------- /doc/plugins/remove_attachment.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Remove Attachment 3 | --- 4 | 5 | The [`remove_attachment`][remove_attachment] plugin allows you to delete 6 | attachments through checkboxes on the web form. 7 | 8 | ```rb 9 | plugin :remove_attachment 10 | ``` 11 | 12 | The plugin adds the `#remove_` accessor to your model, which removes the 13 | attached file if it receives a truthy value: 14 | 15 | ```rb 16 | photo.image #=> # 17 | photo.remove_image = 'true' 18 | photo.image #=> nil 19 | ``` 20 | 21 | This allows you to add a checkbox form field for removing attachments: 22 | 23 | ```rb 24 | form_for photo do |f| 25 | # ... 26 | f.check_box :remove_image 27 | end 28 | ``` 29 | 30 | If you're using the `Shrine::Attacher` directly, you can use the 31 | `Attacher#remove` accessor: 32 | 33 | ```rb 34 | attacher.file #=> # 35 | attacher.remove = '1' 36 | attacher.file #=> nil 37 | ``` 38 | 39 | [remove_attachment]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/remove_attachment.rb 40 | -------------------------------------------------------------------------------- /doc/plugins/remove_invalid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Remove Invalid 3 | --- 4 | 5 | The [`remove_invalid`][remove_invalid] plugin automatically deletes and 6 | deassigns a new assigned file if it was invalid. If there was a previous file 7 | attached, it will be assigned back. 8 | 9 | ```rb 10 | plugin :remove_invalid 11 | ``` 12 | 13 | ```rb 14 | # without previous file 15 | photo.image #=> nil 16 | photo.image = file # validation fails, assignment is reverted 17 | photo.valid? #=> false 18 | photo.image #=> nil 19 | 20 | # with previous file 21 | photo.image #=> # 22 | photo.image = file # validation fails, assignment is reverted 23 | photo.valid? #=> false 24 | photo.image #=> # 25 | ``` 26 | 27 | [remove_invalid]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/remove_invalid.rb 28 | -------------------------------------------------------------------------------- /doc/plugins/restore_cached_data.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Restore Cached Data 3 | --- 4 | 5 | The [`restore_cached_data`][restore_cached_data] plugin re-extracts metadata 6 | when assigning already cached files, i.e. when the attachment has been retained 7 | on validation errors or assigned from a direct upload. In both cases you may 8 | want to re-extract metadata on the server side, mainly to prevent tempering, 9 | but also in case of direct uploads to obtain metadata that couldn't be 10 | extracted on the client side. 11 | 12 | ```rb 13 | plugin :restore_cached_data 14 | ``` 15 | ```rb 16 | photo.image = { "id" => "path/to/image.jpg", "storage" => "cache", "metadata" => {} } 17 | photo.image.metadata #=> { "size" => 4823763, "mime_type" => "image/jpeg", ... } 18 | ``` 19 | 20 | It uses the [`refresh_metadata`][refresh_metadata] plugin to re-extract 21 | metadata. 22 | 23 | [restore_cached_data]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/restore_cached_data.rb 24 | [refresh_metadata]: https://shrinerb.com/docs/plugins/refresh_metadata 25 | -------------------------------------------------------------------------------- /doc/plugins/signature.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Signature 3 | --- 4 | 5 | The [`signature`][signature] plugin provides the ability to calculate a hash 6 | from file content. This hash can be used as a checksum or just as a unique 7 | signature for the uploaded file. 8 | 9 | ```rb 10 | Shrine.plugin :signature 11 | ``` 12 | 13 | ## API 14 | 15 | The plugin adds a `#calculate_signature` instance and class method to the 16 | uploader. The method accepts an IO object and a hashing algorithm, and returns 17 | the calculated hash. 18 | 19 | ```rb 20 | Shrine.calculate_signature(io, :md5) #=> "9a0364b9e99bb480dd25e1f0284c8555" 21 | # or just 22 | Shrine.signature(io, :md5) #=> "9a0364b9e99bb480dd25e1f0284c8555" 23 | ``` 24 | 25 | The following hashing algorithms are supported: SHA1, SHA256, SHA384, SHA512, 26 | MD5, and CRC32. 27 | 28 | You can also choose which format will the calculated hash be encoded in: 29 | 30 | ```rb 31 | Shrine.calculate_signature(io, :sha256, format: :base64) 32 | ``` 33 | 34 | The supported encoding formats are `hex` (default), `base64`, and `none`. 35 | 36 | ## Adding metadata 37 | 38 | You can then use the `add_metadata` plugin to add a new metadata field with the 39 | calculated hash. 40 | 41 | ```rb 42 | plugin :add_metadata 43 | 44 | add_metadata :md5 do |io| 45 | calculate_signature(io, :md5) 46 | end 47 | ``` 48 | 49 | This will generate a hash for each uploaded file, but if you want to generate 50 | one only for the original file, you can add a conditional: 51 | 52 | ```rb 53 | add_metadata :md5 do |io, action: nil, **| 54 | calculate_signature(io, :md5) if action == :cache 55 | end 56 | ``` 57 | 58 | ## Rewinding 59 | 60 | If you want to calculate signature from a non-rewindable IO object, you can 61 | tell Shrine to skip rewinding: 62 | 63 | ```rb 64 | Shrine.calculate_signature(io, :md5, rewind: false) 65 | ``` 66 | 67 | ## Instrumentation 68 | 69 | If the `instrumentation` plugin has been loaded, the `signature` plugin adds 70 | instrumentation around signature calculation. 71 | 72 | ```rb 73 | # instrumentation plugin needs to be loaded *before* signature 74 | plugin :instrumentation 75 | plugin :signature 76 | ``` 77 | 78 | Calculating signature will trigger a `signature.shrine` event with the 79 | following payload: 80 | 81 | | Key | Description | 82 | | :-- | :---- | 83 | | `:io` | The IO object | 84 | | `:uploader` | The uploader class that sent the event | 85 | 86 | A default log subscriber is added as well which logs these events: 87 | 88 | ``` 89 | MIME Type (33ms) – {:io=>StringIO, :uploader=>Shrine} 90 | ``` 91 | 92 | You can also use your own log subscriber: 93 | 94 | ```rb 95 | plugin :signature, log_subscriber: -> (event) { 96 | Shrine.logger.info JSON.generate(name: event.name, duration: event.duration, **event.payload) 97 | } 98 | ``` 99 | ``` 100 | {"name":"signature","duration":24,"io":"#","uploader":"Shrine"} 101 | ``` 102 | 103 | Or disable logging altogether: 104 | 105 | ```rb 106 | plugin :signature, log_subscriber: nil 107 | ``` 108 | 109 | [signature]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/signature.rb 110 | -------------------------------------------------------------------------------- /doc/plugins/tempfile.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tempfile 3 | --- 4 | 5 | The [`tempfile`][tempfile] plugin makes it easier to reuse a single copy of an 6 | uploaded file on disk. 7 | 8 | ```rb 9 | Shrine.plugin :tempfile 10 | ``` 11 | 12 | The plugin provides the `UploadedFile#tempfile` method, which when called on an 13 | open uploaded file will return a copy of its content on disk. The first time 14 | the method is called the file content will cached into a temporary file and 15 | returned. On any subsequent method calls the cached temporary file will be 16 | returned directly. The temporary file is deleted when the uploaded file is 17 | closed. 18 | 19 | ```rb 20 | uploaded_file.open do 21 | # ... 22 | uploaded_file.tempfile #=> # (file is cached) 23 | # ... 24 | uploaded_file.tempfile #=> # (cache is returned) 25 | # ... 26 | end # tempfile is deleted 27 | 28 | # OR 29 | 30 | uploaded_file.open 31 | # ... 32 | uploaded_file.tempfile #=> # (file is cached) 33 | # ... 34 | uploaded_file.tempfile #=> # (cache is returned) 35 | # ... 36 | uploaded_file.close # tempfile is deleted 37 | ``` 38 | 39 | This plugin also modifies `Shrine.with_file` to call `UploadedFile#tempfile` 40 | when the given IO object is an open `UploadedFile`. Since `Shrine.with_file` is 41 | typically called on the `Shrine` class directly, it's recommended to load this 42 | plugin globally. 43 | 44 | [tempfile]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/tempfile.rb 45 | -------------------------------------------------------------------------------- /doc/plugins/upload_options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Upload Options 3 | --- 4 | 5 | The [`upload_options`][upload_options] plugin allows you to automatically pass 6 | additional upload options to storage on every upload: 7 | 8 | ```rb 9 | plugin :upload_options, cache: { acl: "private" } 10 | ``` 11 | 12 | Keys are names of the registered storages, and values are either hashes or 13 | blocks. 14 | 15 | ```rb 16 | plugin :upload_options, store: -> (io, options) do 17 | if options[:derivative] 18 | { acl: "public-read" } 19 | else 20 | { acl: "private" } 21 | end 22 | end 23 | ``` 24 | 25 | If you're uploading the file directly, you can also pass `:upload_options` to 26 | the uploader. 27 | 28 | ```rb 29 | uploader.upload(file, upload_options: { acl: "public-read" }) 30 | ``` 31 | 32 | [upload_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/upload_options.rb 33 | -------------------------------------------------------------------------------- /doc/plugins/url_options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: URL Options 3 | --- 4 | 5 | The [`url_options`][url_options] plugin allows you to specify 6 | URL options that will be applied by default for uploaded files of specified 7 | storages. `url_options` are parameters specific to the storage service. 8 | 9 | ```rb 10 | plugin :url_options, store: { expires_in: 24*60*60 } # `expires_in` is a URL option for AWS S3 11 | ``` 12 | 13 | You can also generate the default URL options dynamically by using a block, 14 | which will receive the UploadedFile object along with any options that were 15 | passed to `UploadedFile#url`. 16 | 17 | ```rb 18 | plugin :url_options, store: -> (file, options) do 19 | { response_content_disposition: ContentDisposition.attachment(file.original_filename) } 20 | end 21 | ``` 22 | 23 | In both cases the default options are merged with options passed to 24 | `UploadedFile#url`, and the latter will always have precedence over default 25 | options. 26 | 27 | [url_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/url_options.rb 28 | -------------------------------------------------------------------------------- /doc/plugins/validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Validation 3 | --- 4 | 5 | The [`validation`][validation] plugin provides a framework for validating 6 | attached files. For some useful validators, see the 7 | [`validation_helpers`][validation_helpers] plugin. 8 | 9 | ```rb 10 | plugin :validation 11 | ``` 12 | 13 | ## Usage 14 | 15 | The `Attacher.validate` method is used to register a validation block, which 16 | is called on attachment: 17 | 18 | ```rb 19 | class VideoUploader < Shrine 20 | Attacher.validate do 21 | if file.duration > 5*60*60 22 | errors << "duration must not be longer than 5 hours" 23 | end 24 | end 25 | end 26 | ``` 27 | ```rb 28 | attacher.assign(file) 29 | attacher.errors #=> ["duration must not be longer than 5 hours"] 30 | ``` 31 | 32 | The validation block is executed in context of a `Shrine::Attacher` instance: 33 | 34 | ```rb 35 | class VideoUploader < Shrine 36 | Attacher.validate do 37 | self #=> # 38 | 39 | file #=> # 40 | record #=> # 41 | name #=> :video 42 | context #=> { ... } 43 | end 44 | end 45 | ``` 46 | 47 | ## Inheritance 48 | 49 | If you're subclassing an uploader that has validations defined, you can call 50 | those validations via `super()`: 51 | 52 | ```rb 53 | class ApplicationUploader < Shrine 54 | Attacher.validate { validate_max_size 5.megabytes } 55 | end 56 | ``` 57 | ```rb 58 | class ImageUploader < ApplicationUploader 59 | Attacher.validate do 60 | super() # empty parentheses are required 61 | validate_mime_type %w[image/jpeg image/png image/webp] 62 | end 63 | end 64 | ``` 65 | 66 | ## Validation options 67 | 68 | You can pass options to the validator via the `:validate` option: 69 | 70 | ```rb 71 | attacher.assign(file, validate: { foo: "bar" }) 72 | ``` 73 | ```rb 74 | class MyUploader < Shrine 75 | Attacher.validate do |**options| 76 | options #=> { foo: "bar" } 77 | end 78 | end 79 | ``` 80 | 81 | You can also skip validation by passing `validate: false`: 82 | 83 | ```rb 84 | attacher.assign(file, validate: false) # skips validation 85 | ``` 86 | 87 | ## Manual validation 88 | 89 | You can also run validation manually via `Attacher#validate`: 90 | 91 | ```rb 92 | attacher.set(uploaded_file) # doesn't trigger validation 93 | attacher.validate # runs validation 94 | ``` 95 | 96 | [validation]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/validation.rb 97 | [validation_helpers]: https://shrinerb.com/docs/plugins/validation_helpers 98 | -------------------------------------------------------------------------------- /doc/release_notes/1.2.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 1.2.0 3 | --- 4 | 5 | New features 6 | ============ 7 | 8 | * `Shrine::Attacher.promote` and `Shrine::Attacher.delete` from the 9 | backgrounding plugin now return the record (unless the record wasn't found or 10 | the action aborted). 11 | 12 | ```rb 13 | class PromoteJob 14 | include Sidekiq::Worker 15 | 16 | def perform(data) 17 | record = Shrine::Attacher.promote(data) 18 | record.update(published: true) if record.is_a?(Post) 19 | end 20 | end 21 | ``` 22 | 23 | Other improvements 24 | ================== 25 | 26 | * Custom metadata (which can be set by overriding `Shrine#extract_metadata`) is 27 | now properly inherited from cached files, and `Shrine#extact_metadata` is now 28 | only called once. 29 | 30 | * If the record was deleted before the background job for promoting started, 31 | instead of erroring, the job now ignores that error and immediately finishes. 32 | 33 | * Fixed a very unlikely scenario where a background job could replaced an 34 | updated attachment on subsequent attachment updates, by wrapping the check if 35 | the attachment changed and updating the attachment in a database transaction. 36 | 37 | * Prevented starting the background job if the attachment has already changed, 38 | which fixes a relatively harmless attack where the end user could create a 39 | lot of retrying jobs by updating the attachment with a corrupted file. 40 | 41 | * All IOs are now closed even if their upload errors. 42 | -------------------------------------------------------------------------------- /doc/release_notes/1.3.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 1.3.0 3 | --- 4 | 5 | New features 6 | ============ 7 | 8 | * Added `:namespace` option to pretty_location plugin for including the model 9 | namespace into the location. 10 | 11 | * Addded `:include_error` option to remote_url plugin which gives you access to 12 | the download error instance when generating the error message. 13 | 14 | * Added `_cached?` and `_stored?` to migration_helpers 15 | for easier handling of uploading logic in ORM hooks: 16 | 17 | ```rb 18 | class Photo < Sequel::Model 19 | def before_save 20 | if column_changed?(:image) && image_stored? # promote 21 | # ... 22 | end 23 | super 24 | end 25 | end 26 | ``` 27 | 28 | Other improvements 29 | ================== 30 | 31 | * Fixed uploading an S3 file making an additional unneeded GET request in 32 | addition to the S3 COPY request. 33 | 34 | * When the model with an attachment is namespaced, the namespace isn't included 35 | in the location anymore when using the pretty_location plugin. 36 | 37 | * Dumped Down version to 2.0.0 to fix downloading files from URLs which have 38 | "[]" characters in them. 39 | 40 | * The IO is now rewinded even if FastImage fails to extract dimensions from the 41 | IO 42 | 43 | * The IO is now rewinded when using MimeMagic to extract the MIME type. 44 | 45 | * When uploading a `Shrine::UploadedFile`, the default location generator will 46 | first look for the extension in `#id` before looking at `#original_filename`. 47 | 48 | * The remote_url plugin now returns separate error messsages when file wasn't 49 | found and when the file was too large. 50 | 51 | * Fixed promoting raising an error if the record got deleted before 52 | promoting finished 53 | 54 | * `UploadedFile#original_filename`, `#size` and `#content_type` now return `nil` 55 | when corresponding metadata keys are missing instead of raising a `KeyError`. 56 | 57 | * Fixed errors being able to accumulate on the record object when errors occur 58 | in data_uri and remote_url plugins. 59 | 60 | * The "metadata" key isn't required anymore when instantiating a 61 | `Shrine::UploadedFile`. 62 | 63 | * The top-level key-values aren't cached anymore when instantiating a 64 | `Shrine::UploadedFile`, which means that `#id`, `#storage_key` and 65 | `#metadata` will now pick up any mutations on the data hash. 66 | 67 | * Removed a deprecation warning in the S3 storage which occurs when AWS 68 | credentials are inferred implicitly. 69 | 70 | * The extracted metadata is now accessible inside `Shrine#generate_location` 71 | via the `:metadata` key in the `context` hash. 72 | 73 | * `Shrine#upload` doesn't mutate the given context hash anymore. 74 | 75 | * `Shrine::Attacher#backup_file` from the backup plugin doesn't modify the 76 | input `UploadedFile` anymore. 77 | 78 | Deprecations 79 | ============ 80 | 81 | * The keep_location plugin is deprecated, as the same behaviour can easily be 82 | achieved by overriding `Shrine#generate_location`: 83 | 84 | ```rb 85 | class ImageUploader < Shrine 86 | def generate_location(io, context) 87 | if io.is_a?(UploadedFile) && context[:phase] == :store 88 | io.id 89 | else 90 | super 91 | end 92 | end 93 | end 94 | ``` 95 | -------------------------------------------------------------------------------- /doc/release_notes/1.4.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 1.4.1 3 | --- 4 | 5 | Regressions 6 | =========== 7 | 8 | * Shrine 1.4.0 introduced a regression where it didn't trigger callbacks for 9 | promoting files anymore. This was done in order to eliminate a tiny chance 10 | for a race condition when same attachment is updated subsequently with 11 | backgrounding. We bring back triggering callbacks on promote. For Sequel we 12 | eliminate the chance of a race condition, while ActiveRecord users can now 13 | add optimistic locking to prevent this. 14 | -------------------------------------------------------------------------------- /doc/release_notes/1.4.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 1.4.2 3 | --- 4 | 5 | Regressions 6 | =========== 7 | 8 | * Version 1.4.1 introduced handling optimistic locking in ActiveRecord plugin, 9 | however the implementation wasn't stable and wouldn't work correctly in 10 | some cases, so we revert the behaviour as it was in version 1.4.0. Developers 11 | can still handle optimistic locking themselves inside background jobs. 12 | 13 | * Version 1.4.1 introduced an instance filter of cached attachment in Sequel 14 | plugin, which makes the same record instance unupdateable after promoting, 15 | even when not using backgrounding, so we revert the behaviour as it was in 16 | version 1.4.0. This is only a problem if you're doing something with the 17 | record instance after updating attachment. 18 | 19 | Improvements 20 | ============ 21 | 22 | * The Sequel plugin now doesn't rescue every `Sequel::Error` on promoting, it 23 | just rescues "Record not found" ones (in Sequel 4.28+ this is a 24 | `Sequel::NoExistingObject`, but prior to that was a generic `Sequel::Error`). 25 | -------------------------------------------------------------------------------- /doc/release_notes/2.0.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.0.1 3 | --- 4 | 5 | Regressions 6 | =========== 7 | 8 | * Fixed versions plugin removing what was set by default_url plugin, if 9 | default_url plugin was loaded before versions 10 | 11 | ```rb 12 | class MyUploader < Shrine 13 | plugin(:default_url) { |context| } 14 | plugin(:versions) # doesn't remove default_url anymore 15 | end 16 | ``` 17 | -------------------------------------------------------------------------------- /doc/release_notes/2.1.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.1.0 3 | --- 4 | 5 | Improvements 6 | ============ 7 | 8 | * The versions plugin doesn't require the `:names` option anymore. 9 | 10 | * The restore_cached_data plugin will now download only a small portion of the 11 | file needed for extracting metadata (after which it will terminate the 12 | connection). Previously the whole file was always downloaded. To get this 13 | you need to update your storage gem. 14 | 15 | * When enforcing that the input is an IO-like object Shrine doesn't check 16 | method arity anymore, only if the object responds to that method. This means 17 | that Shrine now properly works with objects like `Rack::Test::UploadedFile`, 18 | which use `#method_missing` to delegate to the underlying object. 19 | 20 | * Fixed a load order bug with parallelize and logging plugins, where loading 21 | them in this order would make the thread pool join *outside* of the logging 22 | block, making the logs show an instantaneous duration instead of the actual 23 | duration. 24 | 25 | * The `file` command from the determine_mime_type plugin is now executed in the 26 | same way for files as for other IO objects. 27 | 28 | * Increase the amount of bytes read from the IO in determine_mime_type when 29 | using `:file` or `:filemagic` analyzers, which might make the recognize some 30 | MIME types that they haven't before. 31 | 32 | Backwards compatibility 33 | ======================= 34 | 35 | * Generating versions in `:cache` phase is now deprecated, for better security. 36 | 37 | * The `#cached__data=` method that comes from the 38 | cached_attachment_data plugin is now deprecated. The reason for this is that 39 | the following "recommended" usage actually produces bugs when image is 40 | invalid: 41 | 42 | ```rb 43 | form_for @photo do |f| 44 | f.hidden_field :cached_image_data 45 | f.file_filed :image 46 | end 47 | ``` 48 | 49 | The recommended and correct way now to build forms is by keeping the 50 | `#=` setter: 51 | 52 | ```rb 53 | form_for @photo do |f| 54 | f.hidden_field :image, value: @photo.cached_image_data, id: nil 55 | f.file_filed :image 56 | end 57 | ``` 58 | 59 | * The restore_cached_data now sends the `Shrine::UploadedFile` to 60 | `#extract_metadata`, instead of the result of `Storage#open`. 61 | 62 | * Storages which use `#stream` should switch to `Down.open` for remote files, or 63 | the generic `Down::ChunkedIO` in other cases. 64 | -------------------------------------------------------------------------------- /doc/release_notes/2.1.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.1.1 3 | --- 4 | 5 | Regressions 6 | =========== 7 | 8 | * `S3#open` has switched from `Down.download` to `Down.open` in the previous 9 | Shrine version, however the Down version 2.3.3 doesn't require `net/http`, 10 | which means that `S3#open` would raise an "undefined constant Net" error 11 | in case `net/http` wasn't required anywhere else. This is fixed by bumping 12 | the required Down version to 2.3.4, which requires net/http. 13 | -------------------------------------------------------------------------------- /doc/release_notes/2.10.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.10.0 3 | --- 4 | 5 | ## New features 6 | 7 | * The `:mini_magick` analyzer has been added to the `store_dimensions` plugin, 8 | which uses the [MiniMagick] gem to extract image dimensions. 9 | 10 | * The `:ruby_vips` analyzer has been added to the `store_dimensions` plugin, 11 | which uses the [ruby-vips] gem to extract image dimensions. 12 | 13 | * The `:fastimage` analyzer has been added to the `determine_mime_type` plugin, 14 | which uses the [FastImage] gem to determine the MIME type of the file. 15 | 16 | * `Shrine::UploadedFile#download` now accepts a block for downloading an 17 | uploaded file temporarily. This is useful when wanting to validate whether an 18 | uploaded image is valid or corrupted, or when generating thumbnails. 19 | 20 | ```rb 21 | uploaded_file.download do |tempfile| 22 | # ... 23 | end # tempfile is deleted 24 | ``` 25 | 26 | ## Other improvements 27 | 28 | * It's not required that IO objects respond to `#size` anymore. This is 29 | useful when uploading streams of data where the size is not known. 30 | 31 | * The S3 storage now supports IO objects with unknown size. Under the hood it 32 | will use multipart upload. 33 | 34 | * The logger is now properly inherited and shared between `Shrine` subclasses. 35 | 36 | * The attachment URL generated by `download_endpoint` now stays the same 37 | regardless of the order of elements in the metadata hash. Previously it could 38 | change after the uploaded file data is loaded from the database, because the 39 | order of the metadata hash elements would change, which is not desirable for 40 | caching. 41 | 42 | ## Backwards compatibility 43 | 44 | * The `:rack_mime` extension inferrer has been removed from the 45 | `infer_extension` plugin, due to not having acceptable behaviour. The new 46 | default extension inferrer is `:mime_types`. 47 | 48 | * The `:heroku` formatter in the `logging` plugin has been soft-renamed to 49 | `:logfmt`. The `:heroku` alias will stop being supported in Shrine 3. 50 | 51 | * The `Shrine::IO_METHODS` constant has been depreacted, and will become 52 | private in Shrine 3. 53 | 54 | [MiniMagick]: https://github.com/minimagick/minimagick 55 | [ruby-vips]: https://github.com/libvips/ruby-vips 56 | [FastImage]: https://github.com/sdsykes/fastimage 57 | -------------------------------------------------------------------------------- /doc/release_notes/2.10.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.10.1 3 | --- 4 | 5 | ## Regressions 6 | 7 | * Determining MIME type from file extension (using `:mime_types` or 8 | `:mini_mime` analyzers) when file is empty now works again. This was the 9 | behaviour prior to version 2.7.0, but 2.7.0 introduced a regression where 10 | `nil` was returned for empty files, even if they had an extension. 11 | -------------------------------------------------------------------------------- /doc/release_notes/2.11.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.11.0 3 | --- 4 | 5 | ## New features 6 | 7 | * `Shrine::UploadedFile#stream` has been added for streaming the uploaded 8 | content to a writable destination. 9 | 10 | ```rb 11 | uploaded_file.stream(StringIO.new) 12 | # or 13 | uploaded_file.stream("/path/to/destination") 14 | ``` 15 | 16 | * `Shrine.with_file` has been added for temporarily converting an IO-like 17 | object into a file. This is useful when wanting to extract metadata using an 18 | analyzer which requires the source file to be on disk. 19 | 20 | ```rb 21 | add_metadata do |io, context| 22 | movie = Shrine.with_file(io) { |file| FFMPEG::Movie.new(file.path) } 23 | 24 | { "duration" => movie.duration, 25 | "bitrate" => movie.bitrate, 26 | "resolution" => movie.resolution, 27 | "frame_rate" => movie.frame_rate } 28 | end 29 | ``` 30 | 31 | * The `upload_endpoint` plugin now accepts the `Content-MD5` request header, 32 | in which case it will verify the provided checksum. 33 | 34 | * `Shrine::Storage::S3#presign` now accepts `method: :put` for changing from a 35 | POST to a PUT presigned URL. PUT presigned URLs are generally preferred as 36 | they support more parameters, such as `:content_md5` for specifying the 37 | checksum. 38 | 39 | ## Other improvements 40 | 41 | * `Shrine::UploadedFile#download` will now reuse an already opened file, and 42 | in this case will simply rewind it after it's finished. 43 | 44 | * The `:mini_magick` and `:ruby_vips` dimensions analyzers now silently fail 45 | on processing errors, to allow validations to be reached when invalid file 46 | is attached. 47 | 48 | * The `#presign` storage method can now return a Hash. This means it's not 49 | required for result to be wrapped in a `Struct` or `OpenStruct` anymore. 50 | 51 | * The `Shrine::Storage::S3#presign` now also returns a `:method` value 52 | indicating the HTTP verb that needs to be used for the direct upload. 53 | 54 | * The bucket name is not removed from S3 URL path anymore when both `:host` 55 | and `:force_path_style` are set in `Shrine::Storage::S3#url`. 56 | 57 | ## Regressions 58 | 59 | * The MIME type is now correctly determined on empty files for `:mime_types` 60 | and `:mini_mime` analyzers. This regression was introduced in Shrine 2.7.0. 61 | 62 | ## Backwards compatibility 63 | 64 | * The `direct_upload` plugin has been deprecated in favour of `upload_endpoint` 65 | and `presign_endpoint` plugins. The `direct_upload` plugin will be removed in 66 | Shrine 3. 67 | 68 | * `Storage#presign` returning an object that doesn't respond to `#to_h` is now 69 | deprecated, the support for these objects will be removed in Shrine 3. 70 | 71 | * `Shrine::Storage::S3#presign` now returns a `Struct` instead of an 72 | `Aws::S3::PresignedPost` object. Any applications relying on any methods 73 | other than `#url` and `#fields` will have to update their code. 74 | -------------------------------------------------------------------------------- /doc/release_notes/2.12.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.12.0 3 | --- 4 | 5 | ## New features 6 | 7 | * The `Shrine::Attacher#assign_remote_url` method has been added, which acts the 8 | same as `#remote_url=` but allows you to also dynamically provide options to 9 | the underlying downloader (`Down.download` by default). 10 | 11 | ```rb 12 | attacher.assign_remote_url("https://example.com/image.jpg", downloader: { 'Cookie' => 'abc123' }) 13 | ``` 14 | 15 | * The `Shrine::UploadedFile#download_url` method has been added to the 16 | `download_endpoint` plugin for explicitly generating the file URL through 17 | the download endpoint. 18 | 19 | * The `:redirect` option has been added to `download_endpoint` plugin, which 20 | makes download URLs redirect directly to the uploaded file on its storage. 21 | This allows you to avoding streaming the file through your app, and thus not 22 | having it impact your request throughput. 23 | 24 | * The `Shrine::Plugins::ValidationHelpers::PRETTY_FILESIZE` callbable has been 25 | added, which takes the filesize in bytes and returns the representation in 26 | correct unit. This can be used when providing custom validation error messages. 27 | 28 | ```rb 29 | pretty_filesize = Shrine::Plugins::ValidationHelpers::PRETTY_FILESIZE 30 | pretty_filesize.call(1*1024) #=> "1.0 KB" 31 | pretty_filesize.call(1.5*1024*1024) #=> "1.5 MB" 32 | pretty_filesize.call(2*1024*1024*1024) #=> "2.0 GB" 33 | ``` 34 | 35 | ## Other improvements 36 | 37 | * The `Shrine::Storage::FileSystem#open` now forwards any additional options to 38 | `File.open`. 39 | 40 | * The `Shrine::Storage::S3#open` method now accepts the `:rewindable` option to 41 | disable caching read content to disk. 42 | 43 | * In validation error messages in filesize validators in the 44 | `validation_helpers` plugin show specified limits in the appropriate unit, 45 | instead of always using megabytes. 46 | 47 | * `Shrine::UploadedFile#open` will now always open a new IO object, and close 48 | the previous one. This way the uploaded file will be reopened if it's closed. 49 | 50 | * Fixed possible encoding issues in `Shrine::Storage::S3#upload` when filesize 51 | is unknown. 52 | 53 | * The registered storage resolvers in the `dynamic_storage` plugin are now 54 | correctly inherited upon subclassing. 55 | 56 | * The `:file` MIME type analyzer from `determine_mime_type` plugin will now 57 | raise an explicit `Shrine::Error` when the subprocess from the shell command 58 | failed to be spawned. 59 | 60 | * `Attacher#data_uri=` and `Attacher#remote_url=` in addition to `""` now also 61 | ignore `nil` values, instead of raising an exception. 62 | 63 | ## Backwards compatibility 64 | 65 | * The `:storages` option has been deprecated in the `download_endpoint` plugin 66 | in favour of the new `Shrine::UploadedFile#download_url` method. 67 | 68 | * The deprecation of assigning cached versions has been undone, as it can be a 69 | useful feature. 70 | -------------------------------------------------------------------------------- /doc/release_notes/2.16.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.16.0 3 | --- 4 | 5 | ## New Features 6 | 7 | * The `:download_options` option has been added to the `download_endpoint` 8 | plugin, for specifying options passed to `Storage#open`. 9 | 10 | ```rb 11 | plugin :download_endpoint, 12 | download_options: { 13 | sse_customer_algorithm: "AES256", 14 | sse_customer_key: "secret_key", 15 | sse_customer_key_md5: "secret_key_md5", 16 | } 17 | ``` 18 | 19 | * The `:upload_open_options` option has been added to the `derivation_endpoint` 20 | plugin, for specifying options passed to `Storage#open` when downloading a 21 | cached derivation result. 22 | 23 | ```rb 24 | plugin :download_endpoint, 25 | upload: true, 26 | upload_open_options: { response_content_encoding: "gzip" } 27 | ``` 28 | 29 | ## Other improvements 30 | 31 | * The `rack_response` and `derivation_endpoint` plugins now don't return any 32 | `Content-Type` response header if the MIME type could not be determined from 33 | the file extension. Previously it the `Content-Type` header would default to 34 | `application/octet-stream`, which would force the browser to view the file 35 | as generic binary content, as opposed to doing its own MIME type sniffing. 36 | 37 | * Fixed `delete_raw` plugin breaking `derivation_endpoint` when `:upload` was 38 | enabled. 39 | 40 | * Fixed a few things in the `Shrine::Derivation` API: 41 | 42 | * `Derivation#upload` doesn't close the input file anymore 43 | * `Derivation#upload` now requires input file to respond to `#path` 44 | * `Derivation#upload` now deletes the internally generated derivation result 45 | * `Derivation#processed` now works when derivation result is a `File` object 46 | 47 | * The official demo app now shows the `derivation_endpoint` plugin. 48 | 49 | * The `#to_rack_response` method from the `rack_response` plugin now always 50 | opens the `UploadedFile`, and does so upfront. This means if ther are any 51 | download errors, they will bubble up from `#to_rack_response` as opposed to 52 | when the response body is iterated over. 53 | 54 | * When `store_dimensions` plugin was overriding `Shrine#extract_metadata`, it 55 | made the second argument (the `context` hash) mandatory. This has been fixed, 56 | now the second argument is optional again. 57 | -------------------------------------------------------------------------------- /doc/release_notes/2.3.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.3.0 3 | --- 4 | 5 | ## New plugins 6 | 7 | * The `copy` plugin has been added for copying files from one record to 8 | another. Duplicating the model instance will automatically create a copy 9 | of the attachment. 10 | 11 | ```rb 12 | Shrine.plugin :copy 13 | ``` 14 | ```rb 15 | photo = Photo.find(photo_id) 16 | duplicated_photo = photo.dup # duplicates the attachment 17 | 18 | photo.image != duplicated_photo.image 19 | ``` 20 | ```rb 21 | photo.image_attacher.copy(other_photo.image_attacher) 22 | ``` 23 | 24 | ## New Features 25 | 26 | * The `:directory_permissions` option has been added to `Storage::FileSystem` 27 | for specifying UNIX permissions of all directories inside the main directory. 28 | 29 | ## Other improvements 30 | 31 | * Directory permissions are now applied to aoll subdirectories inside the main 32 | directory 33 | 34 | * The default UNIX permissions are now 0644 for files and 0755 for directories. 35 | Previously it relied on defaults of the operating system. 36 | 37 | * The `backgrounding` plugin doesn't require the model instance to have the 38 | `#id=` writer method anymore. 39 | 40 | * The `Attacher#read` method for returning the value of the underlying column 41 | is now public. 42 | 43 | * The `Attacher#context` can now be mutated for an instantiated Attacher object. 44 | 45 | * Fixed `Attacher#swap` method being private after loading `sequel` or 46 | `activerecord` plugin. 47 | 48 | * The `recache` plugin behaviour has been extracted into `Attacher#recache`, so 49 | that it can be used standalone. 50 | 51 | * The `moving` plugin now works correctly with `backup` plugin. 52 | 53 | * The `direct_upload` plugin now prevents the client from caching the presign 54 | response, by returning `Cache-Control: no-store` header. 55 | -------------------------------------------------------------------------------- /doc/release_notes/2.3.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.3.1 3 | --- 4 | 5 | Regressions 6 | =========== 7 | 8 | * The previous version introduced explicit default permissions for created 9 | files and directories in FileSystem storage. However, the current behaviour 10 | was applying directory permissions to the directory the storage is 11 | initialized with. This would raise a `Errno::EPERM` exception for directories 12 | for which the permissions cannot be changed, for example `Dir.tmpdir`. This 13 | is now changed; when FileSystem storage is now initialized with a directory, 14 | the permissions won't be changed if this directory already exists. 15 | -------------------------------------------------------------------------------- /doc/release_notes/2.4.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.4.1 3 | --- 4 | 5 | ## Regressions 6 | 7 | * Prior to version 2.4.0, the conversion of uploaded file data to JSON was 8 | happening in `Attacher#_set`, and `Attacher#write` simply assigned the value 9 | that it received to the data attribute of the model instance. During 10 | refactoring for 2.4.0, the JSON conversion was accidentally moved to 11 | `Attacher#write`, which affects anyone who was using `Attacher#write` 12 | directly and passing it a JSON string, by resulting in a double-encoded 13 | string being assigned to the data attribute. This has now been reverted. 14 | 15 | ## Improvements 16 | 17 | * The `remove_invalid` plugin will now assign back a previous attachment if it 18 | was there, in case the assigned cached file was invalid. Before `nil` was 19 | always assigned. This enables you to display the previous attachment to the 20 | user in case of file validation errors. 21 | 22 | * `UploadedFile#download` will now create the Tempfile using 23 | `UploadedFile#extension`, which handles the case when `#id` doesn't have 24 | extension, but `#original_filename` has (which could be the behaviour of 25 | some custom storages) 26 | 27 | ## Backwards compatibility 28 | 29 | * The `FileSystem#download` method has been deprecated, and will be removed in 30 | Shrine 3. The replacement is to simply use `UploadedFile#download`, which will 31 | use `FileSystem#open` to create a Tempfile in the same way 32 | `FileSystem#download` did previously. This shouldn't affect you unless you 33 | were using `FileSystem#download` directly. 34 | -------------------------------------------------------------------------------- /doc/release_notes/2.6.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.6.1 3 | --- 4 | 5 | ## Bug fixes 6 | 7 | * When `download_endpoint` streams file content into the response body, with 8 | certain storages (FileSystem, SQL) it will use a buffer object when reading 9 | each chunk, which will make that, instead of each read chunk allocating a new 10 | string object, ever new chunk is read into the same buffer string object, 11 | replacing the content from the previous chunk. 12 | 13 | Unfortunately, there are some parts of the Rack/Rails stack which rely on all 14 | chunks co-existing in the memory at the same time (for example the 15 | `Rack::ContentLenth` middleware). For that reason, and to be consistent in 16 | behaviour and memory usage when used with other storages (S3), we modify 17 | `download_endpoint` not to use the buffer object, and have each read chunk be 18 | a new string object. 19 | -------------------------------------------------------------------------------- /doc/release_notes/2.9.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 2.9.0 3 | --- 4 | 5 | ## New Plugins 6 | 7 | * The `infer_extension` plugin has been added, which automatically infers 8 | the upload location extension from the MIME type if it's unknown. This is 9 | useful when using the `data_uri` plugin (where the extension is always 10 | unknown), or the `remote_url` plugin (where the extension might not be 11 | known). 12 | 13 | ```rb 14 | Shrine.plugin :infer_extension 15 | ``` 16 | 17 | ## New Features 18 | 19 | * The `determine_mime_type` plugin has gained built-in support for the [Marcel] 20 | gem, Basecamp's wrapper around the existing [MimeMagic]. 21 | 22 | ```rb 23 | Shrine.plugin :determine_mime_type, analyzer: :marcel 24 | ``` 25 | 26 | * The `versions` plugin now supports saving arrays of files, as well as nested 27 | structures of arrays and hashes. 28 | 29 | ```rb 30 | class PdfUploader < Shrine 31 | plugin :versions 32 | plugin :processing 33 | 34 | process(:store) do |io, context| 35 | [ file1, file2, ... ] 36 | # or 37 | { name1: [ file1, file2, ... ], ... } 38 | # or 39 | [ { name1: file1 }, ... ] 40 | end 41 | end 42 | ``` 43 | 44 | * `UploadedFile#open` can now be called without passing a block. This is useful 45 | if you want to make it explicit when the uploaded file is opened, instead of 46 | it happening automatically when `#read` is called. 47 | 48 | ```rb 49 | uploaded_file.open # opens the local or remote file 50 | uploaded_file.read # read content 51 | uploaded_file.close # close the file 52 | ``` 53 | 54 | * The `Model#_attacher` now accepts options which are forwarded to 55 | `Shrine::Attacher.new`. 56 | 57 | ```rb 58 | photo.image_attacher(store: :other_store) 59 | photo.update(image: file) # uploads to :other_store 60 | ``` 61 | 62 | ## Other improvements 63 | 64 | * Fixed `backgrounding` plugin not detecting options passed to 65 | `Shrine::Attachment.new`. 66 | 67 | * Fixed S3 storage replacing whitespace with `+` symbols in original filename 68 | when assigning object's `:content_disposition` on upload. 69 | 70 | * If an error occurs when downloading the file in `UploadedFile#download` and 71 | `Storage::S3#download`, the tempfile with partially downloaded content will 72 | now automatically be deleted. 73 | 74 | * Shrine now uses the `frozen-string-literal` feature, which will reduce the 75 | number of string allocations. 76 | 77 | ## Backwards compatibility 78 | 79 | * The `:filename` option of the `data_uri` plugin has been deprecated in favour 80 | of the new `infer_extension` plugin. 81 | 82 | * The `multi_delete` plugin has been deprecated, and the `versions` plugin isn't 83 | loading it by default anymore. 84 | 85 | [Marcel]: https://github.com/basecamp/marcel 86 | [MimeMagic]: https://github.com/minad/mimemagic 87 | -------------------------------------------------------------------------------- /doc/release_notes/3.0.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.0.1 3 | --- 4 | 5 | ## Regressions 6 | 7 | * Fixed `metadata_attributes` plugin raising an exception when the attachment 8 | is removed (assigned to `nil`). 9 | 10 | ## Improvements 11 | 12 | * Simplified `UploadedFile#inspect` output: 13 | 14 | ```rb 15 | # before 16 | uploaded_file.inspect 17 | #=> #nil, "size"=>0, "mime_type"=>nil}> 18 | 19 | # after 20 | uploaded_file.inspect 21 | #=> #nil, "size"=>0, "mime_type"=>nil}> 22 | ``` 23 | -------------------------------------------------------------------------------- /doc/release_notes/3.1.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.1.0 3 | --- 4 | 5 | ## New features 6 | 7 | * The `Attacher#create_derivatives` method now accepts a `:storage` option for 8 | specifying the storage to which derivatives should be uploaded. 9 | 10 | ```rb 11 | # with attachment module 12 | photo.image_derivatives!(storage: :other_store) 13 | 14 | # with attacher 15 | attacher.create_derivatives(storage: :other_store) 16 | ``` 17 | 18 | * The `Shrine.calculate_signature` now accepts a `:rewind` boolean option for 19 | choosing whether the IO object should be rewinded after reading. This is 20 | useful if you want to calculate signature from non-rewindable IO objects, 21 | such as `IO.pipe`, `Socket`, non-rewindable `Down::ChunkedIO` etc. 22 | 23 | ```rb 24 | Shrine.signature(io, rewind: false) 25 | ``` 26 | 27 | ## Improvements 28 | 29 | * The derivatives processor can now be registered with `Attacher.derivatives`, 30 | which is just an alias for `Attacher.derivatives_processor`. 31 | 32 | ```rb 33 | class ImageUploader < Shrine 34 | Attacher.derivatives_processor do |original| 35 | # ... 36 | end 37 | end 38 | 39 | # can now be written as 40 | 41 | class ImageUploader < Shrine 42 | Attacher.derivatives do |original| 43 | # ... 44 | end 45 | end 46 | ``` 47 | 48 | * The `Attacher#cached?` and `Attacher#stored?` methods now work correctly if 49 | temporary/permanent storage identifiers were specified as strings. 50 | 51 | * The `store_dimensions` plugin now properly propagates exceptions when loading 52 | the `ruby-vips` gem in `:vips` analyzer. 53 | 54 | * The `add_metadata` plugin now respects inheritance again when defining 55 | metadata methods on the `Shrine::UploadedFile` class. In 2.19.0, the 56 | `add_metadata` plugin was changed to define metadata methods on the internal 57 | `FileMethods` plugin module, which is shared across all uploaders. This 58 | change has now been reverted. 59 | 60 | ## Backwards compatibility 61 | 62 | * The `Attacher#cache_key` and `Attacher#store_key` methods now always return 63 | symbol keys, even if the storage key that was specified was a string key. 64 | 65 | ```rb 66 | attacher = Shrine::Attacher.new(cache: "cache", store: "store") 67 | attacher.cache_key #=> :cache (previously "cache") 68 | attacher.store_key #=> :store (previously "store") 69 | ``` 70 | 71 | * The `add_metadata` plugin now defines metadata methods directly on the 72 | `UploadedFile` class, which means that if you happen to have been overriding 73 | these metadata methods and calling `super`, this won't work anymore. 74 | -------------------------------------------------------------------------------- /doc/release_notes/3.2.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.2.1 3 | --- 4 | 5 | ## Ruby 2.7 compatibility 6 | 7 | * Shrine doesn't trigger [Ruby 2.7 warnings for separation of positional and 8 | keyword arguments][kwargs] anymore. 9 | 10 | * Down 5.1.0 has been released, which resolves warnings and a `FrozenError` 11 | exception on Ruby 2.7. Shrine now requires at least this version of Down. 12 | 13 | If you're using `Down::Http`, make sure you're using http.rb 4.3.0 or newer. 14 | 15 | * ImageProcessing 1.10.3 gem has been released which resolves Ruby 2.7 warnings 16 | as well. If you're using it for image processing, make sure to upgrade to 17 | this version: 18 | 19 | ```rb 20 | gem "image_processing", ">= 1.10.3", "< 2" 21 | ``` 22 | 23 | ## Rack 2.1 compatibility 24 | 25 | * The `derivation_endpoint` plugin now uses `Rack::Files` on Rack 2.1 or newer. 26 | 27 | ## Other improvements 28 | 29 | * The `S3#open` method now handles empty S3 objects. 30 | 31 | [kwargs]: https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/ 32 | -------------------------------------------------------------------------------- /doc/release_notes/3.2.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.2.2 3 | --- 4 | 5 | ## Bug fixes 6 | 7 | * aws-sdk-core 3.104.0 introduced a backwards incompatible changes that caused 8 | `Shrine::Storage::S3#open` to start raising an exception. 9 | 10 | ``` 11 | NoMethodError: undefined method `bytesize' for # 12 | ``` 13 | 14 | This has now been fixed. 15 | -------------------------------------------------------------------------------- /doc/release_notes/3.4.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.4.0 3 | --- 4 | 5 | * Passing attacher options to `Shrine.Attachment` method now works on Ruby 3.0. 6 | 7 | * Defining validation errors as an array of I18n key and options in 8 | `activerecord` plugin now works on Ruby 3.0. 9 | 10 | * The `:fastimage` MIME type analyzer now correctly detects SVGs as 11 | `image/svg+html` in the `determine_mime_type` plugin. 12 | 13 | * The `Shrine::Attacher#read` method provided by the `entity` plugin is now 14 | public. This is consistent with `Shrine::Attacher#write` from `model` plugin 15 | being public as well. 16 | 17 | * The `Shrine::Attacher#reload` method now resets attachment's dirty state. 18 | This means that for a model whose `Attacher#changed?` returns `true`, calling 19 | `#reload` on the model will make `Attacher#changed?` return `false`. This was 20 | the behaviour before Shrine 3.3.0. 21 | 22 | ```rb 23 | # before 24 | model.file_attacher.changed? #=> true 25 | model.reload 26 | model.file_attacher.changed? #=> true 27 | 28 | # after 29 | model.file_attacher.changed? #=> true 30 | model.reload 31 | model.file_attacher.changed? #=> false 32 | ``` 33 | 34 | * Calling `#reload` on the model will not initialize a `Shrine::Attacher` 35 | instance anymore if one hasn't previously been initialized. 36 | -------------------------------------------------------------------------------- /doc/release_notes/3.5.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.5.0 3 | --- 4 | 5 | ## New features 6 | 7 | * The website has been migrated to Docusaurus v2. :sparkles: 8 | 9 | * The `:signer` option has been added to the `derivation_endpoint` plugin, for when you want to use custom URL signing. This is useful when using `:expires_in`, and wanting to have expiring URLs work with CDN caching. 10 | 11 | ```rb 12 | require "aws-sdk-cloudfront" 13 | signer = Aws::CloudFront::UrlSigner.new(key_pair_id: "...", private_key: "...") 14 | 15 | plugin :derivation_endpoint, 16 | expires_in: 90, 17 | signer: -> (url, expires_in:) do 18 | signer.signed_url(url, expires: Time.now.to_i + expires_in) 19 | end 20 | ``` 21 | 22 | * The S3 storage now supports `:max_multipart_parts` option for specifying the maximum number of concurrent parts in which a large file will get uploaded. This number defaults to `10_000`. 23 | 24 | ```rb 25 | Shrine::Storage::S3.new(max_multipart_parts: 1000, ...) 26 | ``` 27 | 28 | * The `:encoding` option can now be passed to `S3#open`, which is applied to downloaded chunks. 29 | 30 | ```rb 31 | io = uploaded_file.open(encoding: Encoding::UTF_8) 32 | csv = CSV.new(io) 33 | # ... 34 | ``` 35 | 36 | ## Other improvements 37 | 38 | * Passing a boolean value to the `#remove_attachment=` setter now works on Ruby 3.2. Previously this would raise an error, because Shrine would try to call `=~` on it, but `Object#=~` method has been removed in Ruby 3.2. 39 | 40 | * When duplicating a model instance, the duplicated attacher now references the duplicated model instance instead of the original one. 41 | 42 | * The download endpoint now returns a `400 Bad Request` response when the serialized file component is invalid. 43 | 44 | * The `derivatives` plugin now supports passing `mutex: false` option to disable usage of a mutex. This makes the `Shrine::Attacher` object marshallable, which should enable using `Marshal.dump` and `Marshal.load` on model instances with attachments. This should be safe unless you're adding derivatives on the same attacher object concurrently. 45 | 46 | * When loading the `derivatives` plugin with `versions_compatibility: true`, this setting doesn't leak to other uploaders anymore. Previously if other uploaders would load `derivatives` plugin without this option, versions compatibility would still get enabled for them. This change also fixes behavior on JRuby. 47 | 48 | * When S3 storage copies files, the AWS tag are not inherited anymore. This allows passing the `:tagging` upload option when promoting from temporary to permanent storage, and have it take effect. 49 | 50 | * The `UploadedFile#url` method doesn't call the obsolete `URI.regexp` method anymore, which should avoid warnings. 51 | 52 | * The `infer_extension` plugin now defines `infer_extension` instance method (in addition to class method) on the uploader for convenience, so that it can be easily called at the uploader instance level. 53 | 54 | ```rb 55 | class MyUploader < Shrine 56 | plugin :infer_extension 57 | 58 | def generate_location(io, metadata:, **) 59 | extension = infer_extension(metadata["mime_type"]) 60 | # ... 61 | end 62 | end 63 | ``` 64 | -------------------------------------------------------------------------------- /doc/release_notes/3.6.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shrine 3.6.0 3 | --- 4 | 5 | ## New features 6 | 7 | * The S3 storage now accepts `:copy_options` when initializing. This can be used for supporting Cloudflare R2 by removing `:tagging_directive` when copying file from temporary to permanent storage. 8 | 9 | ```rb 10 | Shrine::Storage::S3.new(bucket: BUCKET, copy_options: {}, **s3_options) 11 | ``` 12 | 13 | ## Other improvements 14 | 15 | * Rack 3 is now supported. 16 | 17 | * When duplicating the attacher, the `Attacher#context` hash is now copied as well, instead of being kept the same between the two attachers. 18 | 19 | * After `UploadedFile#close` was called, `UploadedFile#opened?` will return `false` and the uploaded file can be implicitly re-opened again. 20 | 21 | ## Backwards compatibility 22 | 23 | * Shrine API that is returning a rack response triple will now return headers as an instance of `Rack::Headers` on Rack 3, which is a subclass of `Hash`. This should keep user code that references header names in mixed case working (in addition to lowercase), but could theoretically cause issues for code explicitly requiring headers to be an instance of `Hash`. 24 | -------------------------------------------------------------------------------- /doc/storage/memory.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Memory 3 | --- 4 | 5 | The Memory storage stores uploaded files in memory, which is suitable for 6 | testing. 7 | 8 | ```rb 9 | Shrine.storages[:store] = Shrine::Storage::Memory.new 10 | ``` 11 | 12 | By default, each storage instance uses a new Hash object for storing files, 13 | but you can pass your own: 14 | 15 | ```rb 16 | my_store = Hash.new 17 | 18 | Shrine.storages[:store] = Shrine::Storage::Memory.new(my_store) 19 | ``` 20 | -------------------------------------------------------------------------------- /doc/validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: File Validation 3 | --- 4 | 5 | Shrine allows validating assigned files using the [`validation`][validation] 6 | plugin. Validation code is defined inside an `Attacher.validate` block: 7 | 8 | ```rb 9 | Shrine.plugin :validation 10 | ``` 11 | ```rb 12 | class ImageUploader < Shrine 13 | Attacher.validate do 14 | # ... perform validation ... 15 | end 16 | end 17 | ``` 18 | 19 | The validation block is run when a new file is assigned, and any validation 20 | errors are stored in `Shrine::Attacher#errors`. Persistence plugins like 21 | `sequel` and `activerecord` will automatically merge these validation errors 22 | into the `#errors` hash on the model instance. 23 | 24 | ```rb 25 | photo = Photo.new 26 | photo.image = image_file 27 | photo.valid? #=> false 28 | photo.errors[:image] #=> [...] 29 | ``` 30 | 31 | ## Validation helpers 32 | 33 | The [`validation_helpers`][validation_helpers] plugin provides convenient 34 | validators for built-in metadata: 35 | 36 | ```rb 37 | Shrine.plugin :validation_helpers 38 | ``` 39 | ```rb 40 | class ImageUploader < Shrine 41 | Attacher.validate do 42 | validate_size 1..5*1024*1024 43 | validate_mime_type %w[image/jpeg image/png image/webp image/tiff] 44 | validate_extension %w[jpg jpeg png webp tiff tif] 45 | end 46 | end 47 | ``` 48 | 49 | Note that for secure MIME type validation it's recommended to also load 50 | `determine_mime_type` and `restore_cached_data` plugins. 51 | 52 | See the [`validation_helpers`][validation_helpers] plugin documentation for 53 | more details. 54 | 55 | ## Custom validations 56 | 57 | You can also do your own custom validations: 58 | 59 | ```rb 60 | # Gemfile 61 | gem "streamio-ffmpeg" 62 | ``` 63 | ```rb 64 | require "streamio-ffmpeg" 65 | 66 | class VideoUploader < Shrine 67 | plugin :add_metadata 68 | 69 | add_metadata :duration do |io| 70 | movie = Shrine.with_file(io) { |file| FFMPEG::Movie.new(file.path) } 71 | movie.duration 72 | end 73 | 74 | Attacher.validate do 75 | if file.duration > 5*60*60 76 | errors << "duration must not be longer than 5 hours" 77 | end 78 | end 79 | end 80 | ``` 81 | 82 | ## Inheritance 83 | 84 | Validations are inherited from superclasses, but you need to call them manually 85 | when defining more validations: 86 | 87 | ```rb 88 | class ApplicationUploader < Shrine 89 | Attacher.validate { validate_max_size 5*1024*1024 } 90 | end 91 | ``` 92 | ```rb 93 | class ImageUploader < ApplicationUploader 94 | Attacher.validate do 95 | super() # empty parentheses are required 96 | validate_mime_type %w[image/jpeg image/png image/webp] 97 | end 98 | end 99 | ``` 100 | 101 | ## Removing invalid files 102 | 103 | By default, an invalid file will remain assigned after validation failed, but 104 | you can have it automatically removed and deleted by loading the 105 | `remove_invalid` plugin. 106 | 107 | ```rb 108 | Shrine.plugin :remove_invalid # remove and delete files that failed validation 109 | ``` 110 | 111 | [validation]: https://shrinerb.com/docs/plugins/validation 112 | [validation_helpers]: https://shrinerb.com/docs/plugins/validation_helpers 113 | -------------------------------------------------------------------------------- /lib/shrine/attachment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | # Core class that provides an attachment interface for a specified attribute 5 | # name, which can be added to model/entity classes. The model/entity plugins 6 | # define the main interface, which delegates to a Shrine::Attacher object. 7 | class Attachment < Module 8 | @shrine_class = ::Shrine 9 | 10 | module ClassMethods 11 | # Returns the Shrine class that this attachment class is 12 | # namespaced under. 13 | attr_accessor :shrine_class 14 | 15 | # Since Attachment is anonymously subclassed when Shrine is subclassed, 16 | # and then assigned to a constant of the Shrine subclass, make inspect 17 | # reflect the likely name for the class. 18 | def inspect 19 | "#{shrine_class.inspect}::Attachment" 20 | end 21 | 22 | # Shorthand for `Attachment.new`. 23 | # 24 | # Shrine::Attachment[:image] 25 | def [](*args, **options) 26 | new(*args, **options) 27 | end 28 | end 29 | 30 | module InstanceMethods 31 | # Instantiates an attachment module for a given attribute name, which 32 | # can then be included to a model class. Second argument will be passed 33 | # to an attacher module. 34 | def initialize(name, **options) 35 | @name = name.to_sym 36 | @options = options 37 | end 38 | 39 | # Returns name of the attachment this module provides. 40 | def attachment_name 41 | @name 42 | end 43 | 44 | # Returns options that are to be passed to the Attacher. 45 | def options 46 | @options 47 | end 48 | 49 | # Returns class name with attachment name included. 50 | # 51 | # Shrine::Attachment.new(:image).to_s #=> "#" 52 | def inspect 53 | "#<#{self.class.inspect}(#{@name})>" 54 | end 55 | alias to_s inspect 56 | 57 | # Returns the Shrine class that this attachment's class is namespaced 58 | # under. 59 | def shrine_class 60 | self.class.shrine_class 61 | end 62 | end 63 | 64 | extend ClassMethods 65 | include InstanceMethods 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/shrine/plugins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | # Module in which all Shrine plugins should be stored. Also contains logic 5 | # for registering and loading plugins. 6 | module Plugins 7 | @plugins = {} 8 | 9 | # If the registered plugin already exists, use it. Otherwise, require it 10 | # and return it. This raises a LoadError if such a plugin doesn't exist, 11 | # or a Shrine::Error if it exists but it does not register itself 12 | # correctly. 13 | def self.load_plugin(name) 14 | unless plugin = @plugins[name] 15 | require "shrine/plugins/#{name}" 16 | raise Error, "plugin #{name} did not register itself correctly in Shrine::Plugins" unless plugin = @plugins[name] 17 | end 18 | plugin 19 | end 20 | 21 | # Delegate call to the plugin in a way that works across Ruby versions. 22 | def self.load_dependencies(plugin, uploader, *args, **kwargs, &block) 23 | return unless plugin.respond_to?(:load_dependencies) 24 | 25 | if kwargs.any? 26 | plugin.load_dependencies(uploader, *args, **kwargs, &block) 27 | else 28 | plugin.load_dependencies(uploader, *args, &block) 29 | end 30 | end 31 | 32 | # Delegate call to the plugin in a way that works across Ruby versions. 33 | def self.configure(plugin, uploader, *args, **kwargs, &block) 34 | return unless plugin.respond_to?(:configure) 35 | 36 | if kwargs.any? 37 | plugin.configure(uploader, *args, **kwargs, &block) 38 | else 39 | plugin.configure(uploader, *args, &block) 40 | end 41 | end 42 | 43 | # Register the given plugin with Shrine, so that it can be loaded using 44 | # `Shrine.plugin` with a symbol. Should be used by plugin files. Example: 45 | # 46 | # Shrine::Plugins.register_plugin(:plugin_name, PluginModule) 47 | def self.register_plugin(name, mod) 48 | @plugins[name] = mod 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/shrine/plugins/_persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Helper plugin that defines persistence methods on the attacher according 6 | # to convention. 7 | # 8 | # plugin :_persistence, plugin: MyPlugin 9 | module Persistence 10 | def self.load_dependencies(uploader, *) 11 | uploader.plugin :atomic_helpers 12 | uploader.plugin :entity 13 | end 14 | 15 | # Using #_persist, #_reload, and #?, defines the 16 | # following methods for a persistence plugin: 17 | # 18 | # * Attacher#persist 19 | # * Attacher#atomic_persist 20 | # * Attacher#atomic_promote 21 | def self.configure(uploader, plugin:) 22 | plugin_name = plugin.to_s.split("::").last.downcase 23 | 24 | plugin::AttacherMethods.module_eval do 25 | define_method :atomic_promote do |**options, &block| 26 | return super(**options, &block) unless send(:"#{plugin_name}?") 27 | 28 | abstract_atomic_promote( 29 | reload: method(:"#{plugin_name}_reload"), 30 | persist: method(:"#{plugin_name}_persist"), 31 | **options, &block 32 | ) 33 | end 34 | 35 | define_method :atomic_persist do |*args, **options, &block| 36 | return super(*args, **options, &block) unless send(:"#{plugin_name}?") 37 | 38 | abstract_atomic_persist( 39 | *args, 40 | reload: method(:"#{plugin_name}_reload"), 41 | persist: method(:"#{plugin_name}_persist"), 42 | **options, &block 43 | ) 44 | end 45 | 46 | define_method :persist do 47 | return super() unless send(:"#{plugin_name}?") 48 | 49 | send(:"#{plugin_name}_persist") 50 | end 51 | 52 | define_method :hash_attribute? do 53 | return super() unless send(:"#{plugin_name}?") 54 | 55 | respond_to?(:"#{plugin_name}_hash_attribute?", true) && 56 | send(:"#{plugin_name}_hash_attribute?") 57 | end 58 | private :hash_attribute? 59 | end 60 | end 61 | 62 | module AttacherMethods 63 | def atomic_promote(*) 64 | raise NotImplementedError, "unhandled by a persistence plugin" 65 | end 66 | 67 | def atomic_persist(*) 68 | raise NotImplementedError, "unhandled by a persistence plugin" 69 | end 70 | 71 | def persist(*) 72 | raise NotImplementedError, "unhandled by a persistence plugin" 73 | end 74 | 75 | # Disable attachment data serialization for data attributes that 76 | # accept and return hashes. 77 | def set_entity(*) 78 | super 79 | @column_serializer = nil if hash_attribute? 80 | end 81 | 82 | private 83 | 84 | # Whether the data attribute accepts and returns hashes. 85 | def hash_attribute? 86 | false 87 | end 88 | end 89 | end 90 | 91 | register_plugin(:_persistence, Persistence) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/shrine/plugins/_urlsafe_serialization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "base64" 4 | require "json" 5 | 6 | class Shrine 7 | module Plugins 8 | # This is an internal plugin used by download_endpoint and 9 | # derivation_endpoint plugins. 10 | module UrlsafeSerialization 11 | module ClassMethods 12 | def urlsafe_serialize(hash) 13 | urlsafe_serializer.encode(hash) 14 | end 15 | 16 | def urlsafe_deserialize(string) 17 | urlsafe_serializer.decode(string) 18 | end 19 | 20 | def urlsafe_serializer 21 | Serializer.new 22 | end 23 | end 24 | 25 | module FileMethods 26 | def urlsafe_dump(**options) 27 | self.class.urlsafe_dump(self, **options) 28 | end 29 | 30 | def urlsafe_data(metadata: []) 31 | data = self.data.dup 32 | 33 | if metadata.any? 34 | # order metadata in the specified order 35 | data["metadata"] = metadata 36 | .map { |name| [name, self.metadata[name]] } 37 | .to_h 38 | else 39 | # save precious characters 40 | data.delete("metadata") 41 | end 42 | 43 | data 44 | end 45 | end 46 | 47 | module FileClassMethods 48 | def urlsafe_dump(file, **options) 49 | data = file.urlsafe_data(**options) 50 | 51 | shrine_class.urlsafe_serialize(data) 52 | end 53 | 54 | def urlsafe_load(string) 55 | data = shrine_class.urlsafe_deserialize(string) 56 | 57 | new(data) 58 | end 59 | end 60 | 61 | class Serializer 62 | def encode(data) 63 | base64_encode(json_encode(data)) 64 | end 65 | 66 | def decode(data) 67 | json_decode(base64_decode(data)) 68 | end 69 | 70 | private 71 | 72 | def json_encode(data) 73 | JSON.generate(data) 74 | end 75 | 76 | def base64_encode(data) 77 | Base64.urlsafe_encode64(data, padding: false) 78 | end 79 | 80 | def base64_decode(data) 81 | Base64.urlsafe_decode64(data) 82 | end 83 | 84 | def json_decode(data) 85 | JSON.parse(data) 86 | end 87 | end 88 | end 89 | 90 | register_plugin(:_urlsafe_serialization, UrlsafeSerialization) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/shrine/plugins/add_metadata.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/add_metadata 6 | module AddMetadata 7 | def self.configure(uploader) 8 | uploader.opts[:add_metadata] ||= { definitions: [] } 9 | end 10 | 11 | module ClassMethods 12 | def add_metadata(name = nil, **options, &block) 13 | opts[:add_metadata][:definitions] << [name, options, block] 14 | 15 | metadata_method(name) if name 16 | end 17 | 18 | def metadata_method(*names) 19 | names.each { |name| _metadata_method(name) } 20 | end 21 | 22 | private 23 | 24 | def _metadata_method(name) 25 | self::UploadedFile.send(:define_method, name) do 26 | metadata[name.to_s] 27 | end 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def extract_metadata(io, **options) 33 | metadata = super 34 | 35 | extract_custom_metadata(io, **options, metadata: metadata) 36 | 37 | metadata 38 | end 39 | 40 | private 41 | 42 | def extract_custom_metadata(io, **options) 43 | opts[:add_metadata][:definitions].each do |name, definition_options, block| 44 | result = instance_exec(io, **options, &block) 45 | 46 | if result.nil? && definition_options[:skip_nil] 47 | # Do not store this metadata 48 | elsif name 49 | options[:metadata].merge! name.to_s => result 50 | else 51 | options[:metadata].merge! result.transform_keys(&:to_s) if result 52 | end 53 | 54 | # rewind between metadata blocks 55 | io.rewind 56 | end 57 | end 58 | end 59 | 60 | module AttacherMethods 61 | def add_metadata(new_metadata, &block) 62 | file!.add_metadata(new_metadata, &block) 63 | set(file) # trigger model write 64 | end 65 | end 66 | 67 | module FileMethods 68 | def add_metadata(new_metadata, &block) 69 | @metadata = @metadata.merge(new_metadata, &block) 70 | end 71 | end 72 | end 73 | 74 | register_plugin(:add_metadata, AddMetadata) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/shrine/plugins/cached_attachment_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/cached_attachment_data 6 | module CachedAttachmentData 7 | module AttachmentMethods 8 | def define_model_methods(name) 9 | super if defined?(super) 10 | 11 | define_method :"cached_#{name}_data" do 12 | send(:"#{name}_attacher").cached_data 13 | end 14 | end 15 | end 16 | 17 | module AttacherMethods 18 | def cached_data 19 | file.to_json if cached? && changed? 20 | end 21 | end 22 | end 23 | 24 | register_plugin(:cached_attachment_data, CachedAttachmentData) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/shrine/plugins/default_storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/default_storage 6 | module DefaultStorage 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:default_storage] ||= {} 9 | uploader.opts[:default_storage].merge!(opts) 10 | end 11 | 12 | module AttacherClassMethods 13 | def default_cache(value = nil, &block) 14 | default_storage.merge!(cache: value || block) 15 | end 16 | 17 | def default_store(value = nil, &block) 18 | default_storage.merge!(store: value || block) 19 | end 20 | 21 | private 22 | 23 | def default_storage 24 | shrine_class.opts[:default_storage] 25 | end 26 | end 27 | 28 | module AttacherMethods 29 | def initialize(**options) 30 | super(**shrine_class.opts[:default_storage], **options) 31 | end 32 | 33 | def cache_key 34 | if @cache.respond_to?(:call) 35 | if @cache.arity == 2 36 | Shrine.deprecation("Passing record & name argument to default storage block is deprecated and will be removed in Shrine 4. Use a block without arguments instead.") 37 | @cache.call(record, name).to_sym 38 | else 39 | instance_exec(&@cache).to_sym 40 | end 41 | else 42 | super 43 | end 44 | end 45 | 46 | def store_key 47 | if @store.respond_to?(:call) 48 | if @store.arity == 2 49 | Shrine.deprecation("Passing record & name argument to default storage block is deprecated and will be removed in Shrine 4. Use a block without arguments instead.") 50 | @store.call(record, name).to_sym 51 | else 52 | instance_exec(&@store).to_sym 53 | end 54 | else 55 | super 56 | end 57 | end 58 | end 59 | end 60 | 61 | register_plugin(:default_storage, DefaultStorage) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/shrine/plugins/default_url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/default_url 6 | module DefaultUrl 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:default_url] ||= {} 9 | uploader.opts[:default_url].merge!(opts) 10 | end 11 | 12 | module AttacherClassMethods 13 | def default_url(&block) 14 | shrine_class.opts[:default_url][:block] = block 15 | end 16 | end 17 | 18 | module AttacherMethods 19 | def url(**options) 20 | super || default_url(**options) 21 | end 22 | 23 | private 24 | 25 | def default_url(**options) 26 | return unless default_url_block 27 | 28 | url = instance_exec(**options, &default_url_block) 29 | 30 | [*default_url_host, url].join 31 | end 32 | 33 | def default_url_block 34 | shrine_class.opts[:default_url][:block] 35 | end 36 | 37 | def default_url_host 38 | shrine_class.opts[:default_url][:host] 39 | end 40 | end 41 | end 42 | 43 | register_plugin(:default_url, DefaultUrl) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/shrine/plugins/default_url_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shrine.deprecation("The default_url_options plugin has been renamed to url_options, so `plugin :default_url_options` should be replaced with `plugin :url_options`. The default_url_options alias will be removed in Shrine 4.") 4 | 5 | require "shrine/plugins/url_options" 6 | 7 | Shrine::Plugins.register_plugin(:default_url_options, Shrine::Plugins::UrlOptions) 8 | -------------------------------------------------------------------------------- /lib/shrine/plugins/delete_raw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shrine.deprecation("The delete_raw plugin is deprecated and will be removed in Shrine 4. If you were using it with versions plugin, use the new derivatives plugin instead.") 4 | 5 | class Shrine 6 | module Plugins 7 | # Documentation can be found on https://shrinerb.com/docs/plugins/delete_raw 8 | module DeleteRaw 9 | def self.configure(uploader, **opts) 10 | uploader.opts[:delete_raw] ||= {} 11 | uploader.opts[:delete_raw].merge!(opts) 12 | end 13 | 14 | module InstanceMethods 15 | private 16 | 17 | # Deletes the file that was uploaded, unless it's an UploadedFile. 18 | def _upload(io, delete: delete_raw?, **options) 19 | super(io, delete: delete, **options) 20 | end 21 | 22 | def delete_raw? 23 | opts[:delete_raw][:storages].nil? || 24 | opts[:delete_raw][:storages].include?(storage_key) 25 | end 26 | end 27 | end 28 | 29 | register_plugin(:delete_raw, DeleteRaw) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/shrine/plugins/dynamic_storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/dynamic_storage 6 | module DynamicStorage 7 | def self.configure(uploader) 8 | uploader.opts[:dynamic_storage] ||= { resolvers: {} } 9 | end 10 | 11 | module ClassMethods 12 | def storage(regex, &block) 13 | opts[:dynamic_storage][:resolvers][regex] = block 14 | end 15 | 16 | def find_storage(name) 17 | resolve_dynamic_storage(name) or super 18 | end 19 | 20 | private 21 | 22 | def resolve_dynamic_storage(name) 23 | opts[:dynamic_storage][:resolvers].each do |regex, block| 24 | if match = name.to_s.match(regex) 25 | return block.call(match) 26 | end 27 | end 28 | nil 29 | end 30 | end 31 | end 32 | 33 | register_plugin(:dynamic_storage, DynamicStorage) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/shrine/plugins/included.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/included 6 | module Included 7 | def self.configure(uploader, &block) 8 | uploader.opts[:included] ||= {} 9 | uploader.opts[:included][:block] = block 10 | end 11 | 12 | module AttachmentMethods 13 | def included(klass) 14 | super 15 | 16 | klass.instance_exec(@name, &shrine_class.opts[:included][:block]) 17 | end 18 | end 19 | end 20 | 21 | register_plugin(:included, Included) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/shrine/plugins/keep_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/keep_files 6 | module KeepFiles 7 | module AttacherMethods 8 | def destroy? 9 | false 10 | end 11 | end 12 | end 13 | 14 | register_plugin(:keep_files, KeepFiles) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/shrine/plugins/metadata_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/metadata_attributes 6 | module MetadataAttributes 7 | def self.load_dependencies(uploader, *) 8 | uploader.plugin :entity 9 | end 10 | 11 | def self.configure(uploader, **opts) 12 | uploader.opts[:metadata_attributes] ||= {} 13 | uploader.opts[:metadata_attributes].merge!(opts) 14 | end 15 | 16 | module AttacherClassMethods 17 | def metadata_attributes(mappings = nil) 18 | if mappings 19 | shrine_class.opts[:metadata_attributes].merge!(mappings) 20 | else 21 | shrine_class.opts[:metadata_attributes] 22 | end 23 | end 24 | end 25 | 26 | module AttacherMethods 27 | def column_values 28 | super.merge(metadata_attributes) 29 | end 30 | 31 | private 32 | 33 | def metadata_attributes 34 | values = {} 35 | 36 | self.class.metadata_attributes.each do |source, destination| 37 | metadata_attribute = destination.is_a?(Symbol) ? :"#{name}_#{destination}" : :"#{destination}" 38 | 39 | next unless record.respond_to?(metadata_attribute) 40 | 41 | values[metadata_attribute] = file && file.metadata[source.to_s] 42 | end 43 | 44 | values 45 | end 46 | end 47 | end 48 | 49 | register_plugin(:metadata_attributes, MetadataAttributes) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/shrine/plugins/module_include.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shrine.deprecation("The module_include plugin is deprecated and will be removed in Shrine 4. Override core classes directly instead.") 4 | 5 | class Shrine 6 | module Plugins 7 | # Documentation can be found on https://shrinerb.com/docs/plugins/module_include 8 | module ModuleInclude 9 | module ClassMethods 10 | def attachment_module(mod = nil, &block) 11 | module_include(self::Attachment, mod, &block) 12 | end 13 | 14 | def attacher_module(mod = nil, &block) 15 | module_include(self::Attacher, mod, &block) 16 | end 17 | 18 | def file_module(mod = nil, &block) 19 | module_include(self::UploadedFile, mod, &block) 20 | end 21 | 22 | private 23 | 24 | def module_include(klass, mod, &block) 25 | mod ||= Module.new(&block) 26 | klass.include(mod) 27 | end 28 | end 29 | end 30 | 31 | register_plugin(:module_include, ModuleInclude) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/shrine/plugins/multi_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/multi_cache 6 | module MultiCache 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:multi_cache] ||= {} 9 | uploader.opts[:multi_cache].merge!(opts) 10 | end 11 | 12 | module AttacherMethods 13 | def cached?(file = self.file) 14 | super || additional_cache.any? { |key| uploaded?(file, key) } 15 | end 16 | 17 | private 18 | 19 | def additional_cache 20 | Array(shrine_class.opts[:multi_cache][:additional_cache]) 21 | end 22 | end 23 | end 24 | 25 | register_plugin(:multi_cache, MultiCache) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/shrine/plugins/pretty_location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/pretty_location 6 | module PrettyLocation 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:pretty_location] ||= { identifier: :id } 9 | uploader.opts[:pretty_location].merge!(opts) 10 | end 11 | 12 | module InstanceMethods 13 | def generate_location(io, **options) 14 | pretty_location(io, **options) 15 | end 16 | 17 | def pretty_location(io, name: nil, record: nil, version: nil, derivative: nil, identifier: nil, metadata: {}, **) 18 | if record 19 | namespace = record_namespace(record) 20 | identifier ||= record_identifier(record) 21 | end 22 | 23 | basename = basic_location(io, metadata: metadata) 24 | basename = [*(version || derivative), basename].join("-") 25 | 26 | [*namespace, *identifier, *name, basename].join("/") 27 | end 28 | 29 | private 30 | 31 | def record_identifier(record) 32 | record.public_send(opts[:pretty_location][:identifier]) 33 | end 34 | 35 | def transform_class_name(class_name) 36 | if opts[:pretty_location][:class_underscore] 37 | class_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z])([A-Z])/, '\1_\2').downcase 38 | else 39 | class_name.downcase 40 | end 41 | end 42 | 43 | def record_namespace(record) 44 | class_name = record.class.name or return 45 | parts = transform_class_name(class_name).split("::") 46 | 47 | if separator = opts[:pretty_location][:namespace] 48 | parts.join(separator) 49 | else 50 | parts.last 51 | end 52 | end 53 | end 54 | end 55 | 56 | register_plugin(:pretty_location, PrettyLocation) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/shrine/plugins/processing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shrine.deprecation("The processing plugin is deprecated and will be removed in Shrine 4. If you were using it with versions plugin, use the new derivatives plugin instead.") 4 | 5 | class Shrine 6 | module Plugins 7 | # Documentation can be found on https://shrinerb.com/docs/plugins/processing 8 | module Processing 9 | def self.configure(uploader) 10 | uploader.opts[:processing] ||= {} 11 | end 12 | 13 | module ClassMethods 14 | def process(action, &block) 15 | opts[:processing][action] ||= [] 16 | opts[:processing][action] << block 17 | end 18 | end 19 | 20 | module InstanceMethods 21 | def upload(io, process: true, **options) 22 | if process 23 | input = process(io, **options) 24 | else 25 | input = io 26 | end 27 | 28 | super(input, **options) 29 | end 30 | 31 | private 32 | 33 | def process(io, **options) 34 | pipeline = processing_pipeline(options[:action]) 35 | pipeline.inject(io) do |input, processor| 36 | instance_exec(input, **options, &processor) || input 37 | end 38 | end 39 | 40 | def processing_pipeline(key) 41 | opts[:processing][key] || [] 42 | end 43 | end 44 | end 45 | 46 | register_plugin(:processing, Processing) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/shrine/plugins/rack_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | class Shrine 6 | module Plugins 7 | # Documentation can be found on https://shrinerb.com/docs/plugins/rack_file 8 | module RackFile 9 | module ClassMethods 10 | # Accepts a Rack uploaded file hash and wraps it in an IO object. 11 | def rack_file(hash) 12 | if hash[:filename] 13 | # Rack can sometimes return the filename binary encoded, so we force 14 | # the encoding to utf-8 15 | hash = hash.merge( 16 | filename: hash[:filename].dup.force_encoding(Encoding::UTF_8) 17 | ) 18 | end 19 | 20 | Shrine::RackFile.new(hash) 21 | end 22 | end 23 | 24 | module AttacherMethods 25 | # Checks whether a file is a Rack file hash, and in that case wraps the 26 | # hash in an IO-like object. 27 | def assign(value, **options) 28 | if rack_file?(value) 29 | assign shrine_class.rack_file(value), **options 30 | else 31 | super 32 | end 33 | end 34 | 35 | private 36 | 37 | # Returns whether a given value is a Rack uploaded file hash, by 38 | # checking whether it's a hash with `:tempfile` and `:name` keys. 39 | def rack_file?(value) 40 | value.is_a?(Hash) && value.key?(:tempfile) && value.key?(:name) 41 | end 42 | end 43 | 44 | end 45 | 46 | register_plugin(:rack_file, RackFile) 47 | end 48 | 49 | # This is used to wrap the Rack hash into an IO-like object which Shrine 50 | # can upload. 51 | class RackFile 52 | attr_reader :tempfile, :original_filename, :content_type 53 | alias :to_io :tempfile 54 | 55 | def initialize(hash) 56 | @tempfile = hash[:tempfile] 57 | @original_filename = hash[:filename] 58 | @content_type = hash[:type] 59 | end 60 | 61 | def path 62 | @tempfile.path 63 | end 64 | 65 | extend Forwardable 66 | delegate [:read, :size, :rewind, :eof?, :close] => :@tempfile 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/shrine/plugins/recache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shrine.deprecation("The recache plugin is deprecated and will be removed in Shrine 4. If you were using it with versions plugin, use the new derivatives plugin instead.") 4 | 5 | class Shrine 6 | module Plugins 7 | # Documentation can be found on https://shrinerb.com/docs/plugins/recache 8 | module Recache 9 | module AttacherMethods 10 | def save 11 | recache 12 | super 13 | end 14 | 15 | def recache 16 | if cached? 17 | result = upload(file, cache_key, action: :recache) 18 | 19 | set(result) 20 | end 21 | end 22 | end 23 | end 24 | 25 | register_plugin(:recache, Recache) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/shrine/plugins/refresh_metadata.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/refresh_metadata 6 | module RefreshMetadata 7 | module AttacherMethods 8 | def refresh_metadata!(**options) 9 | file!.refresh_metadata!(**context, **options) 10 | set(file) # trigger model write 11 | end 12 | end 13 | 14 | module FileMethods 15 | def refresh_metadata!(**options) 16 | return open { refresh_metadata!(**options) } unless opened? 17 | 18 | refreshed_metadata = uploader.send(:get_metadata, self, metadata: true, **options) 19 | 20 | @metadata = @metadata.merge(refreshed_metadata) 21 | end 22 | end 23 | end 24 | 25 | register_plugin(:refresh_metadata, RefreshMetadata) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/shrine/plugins/remove_attachment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/remove_attachment 6 | module RemoveAttachment 7 | module AttachmentMethods 8 | def define_model_methods(name) 9 | super if defined?(super) 10 | 11 | define_method :"remove_#{name}=" do |value| 12 | send(:"#{name}_attacher").remove = value 13 | end 14 | 15 | define_method :"remove_#{name}" do 16 | send(:"#{name}_attacher").remove 17 | end 18 | end 19 | end 20 | 21 | module AttacherMethods 22 | # We remove the attachment if the value evaluates to true. 23 | def remove=(value) 24 | @remove = value 25 | 26 | change(nil) if remove? 27 | end 28 | 29 | def remove 30 | @remove 31 | end 32 | 33 | private 34 | 35 | # Don't override previously removed attachment that wasn't yet deleted. 36 | def change?(file) 37 | super && !(changed? && remove?) 38 | end 39 | 40 | # Rails sends "0" or "false" if the checkbox hasn't been ticked. 41 | def remove? 42 | return remove if [true, false].include?(remove) 43 | 44 | remove && remove != "" && remove !~ /\A(0|false)\z/ 45 | end 46 | end 47 | end 48 | 49 | register_plugin(:remove_attachment, RemoveAttachment) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/shrine/plugins/remove_invalid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/remove_invalid 6 | module RemoveInvalid 7 | def self.load_dependencies(uploader) 8 | uploader.plugin :validation 9 | end 10 | 11 | module AttacherMethods 12 | def validate(*) 13 | super 14 | ensure 15 | deassign if errors.any? 16 | end 17 | 18 | private 19 | 20 | def deassign 21 | destroy 22 | 23 | if changed? 24 | load_data @previous.data 25 | @previous = nil 26 | else 27 | load_data nil 28 | end 29 | end 30 | end 31 | end 32 | 33 | register_plugin(:remove_invalid, RemoveInvalid) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/shrine/plugins/restore_cached_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/restore_cached_data 6 | module RestoreCachedData 7 | def self.load_dependencies(uploader) 8 | uploader.plugin :refresh_metadata 9 | end 10 | 11 | module AttacherMethods 12 | private 13 | 14 | def cached(value, **options) 15 | cached_file = super 16 | 17 | # TODO: Remove this conditional when we remove the versions plugin 18 | if cached_file.is_a?(Hash) || cached_file.is_a?(Array) 19 | uploaded_file(cached_file) { |file| file.refresh_metadata!(**context, **options) } 20 | else 21 | cached_file.refresh_metadata!(**context, **options) 22 | end 23 | 24 | cached_file 25 | end 26 | end 27 | end 28 | 29 | register_plugin(:restore_cached_data, RestoreCachedData) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/shrine/plugins/tempfile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/tempfile 6 | module Tempfile 7 | module ClassMethods 8 | def with_file(io) 9 | if io.is_a?(UploadedFile) && io.opened? 10 | # open a new file descriptor for thread safety 11 | File.open(io.tempfile.path, binmode: true) do |file| 12 | yield file 13 | end 14 | else 15 | super 16 | end 17 | end 18 | end 19 | 20 | module FileMethods 21 | def tempfile 22 | raise Error, "uploaded file must be opened" unless @io 23 | 24 | @tempfile ||= download 25 | @tempfile.rewind 26 | @tempfile 27 | end 28 | 29 | def close 30 | super 31 | 32 | @tempfile.close! if @tempfile 33 | @tempfile = nil 34 | end 35 | end 36 | end 37 | 38 | register_plugin(:tempfile, Tempfile) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/shrine/plugins/upload_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/upload_options 6 | module UploadOptions 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:upload_options] ||= {} 9 | uploader.opts[:upload_options].merge!(opts) 10 | end 11 | 12 | module InstanceMethods 13 | private 14 | 15 | def _upload(io, **options) 16 | upload_options = get_upload_options(io, options) 17 | 18 | super(io, **options, upload_options: upload_options) 19 | end 20 | 21 | def get_upload_options(io, options) 22 | upload_options = opts[:upload_options][storage_key] || {} 23 | upload_options = upload_options.call(io, options) if upload_options.respond_to?(:call) 24 | upload_options = upload_options.merge(options[:upload_options]) if options[:upload_options] 25 | upload_options 26 | end 27 | end 28 | end 29 | 30 | register_plugin(:upload_options, UploadOptions) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/shrine/plugins/url_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/url_options 6 | module UrlOptions 7 | def self.configure(uploader, **opts) 8 | uploader.opts[:url_options] ||= {} 9 | uploader.opts[:url_options].merge!(opts) 10 | end 11 | 12 | module FileMethods 13 | def url(**options) 14 | default_options = url_options(options) 15 | 16 | super(**default_options, **options) 17 | end 18 | 19 | private 20 | 21 | def url_options(options) 22 | default_options = shrine_class.opts[:url_options][storage_key] 23 | default_options = default_options.call(self, options) if default_options.respond_to?(:call) 24 | default_options || {} 25 | end 26 | end 27 | end 28 | 29 | register_plugin(:url_options, UrlOptions) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/shrine/plugins/validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | module Plugins 5 | # Documentation can be found on https://shrinerb.com/docs/plugins/validation 6 | module Validation 7 | module AttacherClassMethods 8 | # Block that is executed in context of Shrine::Attacher during 9 | # validation. Example: 10 | # 11 | # Shrine::Attacher.validate do 12 | # if file.size > 5*1024*1024 13 | # errors << "is too big (max is 5 MB)" 14 | # end 15 | # end 16 | def validate(&block) 17 | private define_method(:validate_block, &block) 18 | end 19 | end 20 | 21 | module AttacherMethods 22 | # Returns an array of validation errors created on file assignment in 23 | # the `Attacher.validate` block. 24 | attr_reader :errors 25 | 26 | # Initializes validation errors to an empty array. 27 | def initialize(**options) 28 | super 29 | @errors = [] 30 | end 31 | 32 | # Performs validations after attaching cached file. 33 | def attach_cached(value, validate: nil, **options) 34 | result = super(value, validate: false, **options) 35 | validation(validate) 36 | result 37 | end 38 | 39 | # Performs validations after attaching file. 40 | def attach(io, validate: nil, **options) 41 | result = super(io, **options) 42 | validation(validate) 43 | result 44 | end 45 | 46 | # Runs the validation defined by `Attacher.validate`. 47 | def validate(**options) 48 | errors.clear 49 | _validate(**options) if attached? 50 | end 51 | 52 | private 53 | 54 | # Calls validation appropriately based on the :validate value. 55 | def validation(argument) 56 | case argument 57 | when Hash then validate(**argument) 58 | when false then errors.clear # skip validation 59 | else validate 60 | end 61 | end 62 | 63 | # Calls #validate_block, passing it accepted parameters. 64 | def _validate(**options) 65 | if method(:validate_block).arity.zero? 66 | validate_block 67 | else 68 | validate_block(**options) 69 | end 70 | end 71 | 72 | # Overridden by the `Attacher.validate` block. 73 | def validate_block(**options) 74 | end 75 | end 76 | end 77 | 78 | register_plugin(:validation, Validation) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/shrine/storage/memory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shrine" 4 | require "stringio" 5 | 6 | class Shrine 7 | module Storage 8 | class Memory 9 | attr_reader :store 10 | 11 | def initialize(store = {}) 12 | @store = store 13 | end 14 | 15 | def upload(io, id, **) 16 | store[id] = io.read 17 | end 18 | 19 | def open(id, **) 20 | io = StringIO.new(store.fetch(id)) 21 | io.set_encoding(io.string.encoding) # Ruby 2.7.0 – https://bugs.ruby-lang.org/issues/16497 22 | io 23 | rescue KeyError 24 | raise Shrine::FileNotFound, "file #{id.inspect} not found on storage" 25 | end 26 | 27 | def exists?(id) 28 | store.key?(id) 29 | end 30 | 31 | def url(id, *) 32 | "memory://#{id}" 33 | end 34 | 35 | def delete(id) 36 | store.delete(id) 37 | end 38 | 39 | def delete_prefixed(delete_prefix) 40 | delete_prefix = delete_prefix.chomp("/") + "/" 41 | store.delete_if { |key, _value| key.start_with?(delete_prefix) } 42 | end 43 | 44 | def clear! 45 | store.clear 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/shrine/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Shrine 4 | def self.version 5 | Gem::Version.new VERSION::STRING 6 | end 7 | 8 | module VERSION 9 | MAJOR = 3 10 | MINOR = 6 11 | TINY = 0 12 | PRE = nil 13 | 14 | STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/attachment_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Shrine::Attachment do 4 | before do 5 | @shrine = shrine 6 | end 7 | 8 | describe ".[]" do 9 | it "calls .new" do 10 | attachment = @shrine::Attachment[:file, foo: "bar"] 11 | 12 | assert_instance_of @shrine::Attachment, attachment 13 | 14 | assert_equal :file, attachment.attachment_name 15 | assert_equal Hash[foo: "bar"], attachment.options 16 | end 17 | end 18 | 19 | describe "#initialize" do 20 | it "symbolizes attachment name" do 21 | attachment = @shrine::Attachment.new("file") 22 | 23 | assert_equal :file, attachment.attachment_name 24 | end 25 | 26 | it "accepts additional options" do 27 | attachment = @shrine::Attachment.new(:file, foo: "bar") 28 | 29 | assert_equal Hash[foo: "bar"], attachment.options 30 | end 31 | end 32 | 33 | describe "#inspect" do 34 | it "is simplified" do 35 | attachment = @shrine::Attachment.new(:file) 36 | 37 | assert_match "Attachment(file)", attachment.inspect 38 | end 39 | end 40 | 41 | describe "#to_s" do 42 | it "is simplified" do 43 | attachment = @shrine::Attachment.new(:file) 44 | 45 | assert_match "Attachment(file)", attachment.to_s 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrinerb/shrine/1ce6da4be0ca62ecd5776f107fcd0d657b773f28/test/fixtures/image.jpg -------------------------------------------------------------------------------- /test/integration/sequel_backgrounding_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "./test/support/sequel" 3 | 4 | describe "Sequel Backgrounding" do 5 | before do 6 | @shrine = shrine do 7 | plugin :sequel 8 | plugin :backgrounding 9 | end 10 | 11 | user_class = Class.new(Sequel::Model) 12 | user_class.set_dataset(:users) 13 | user_class.include @shrine::Attachment.new(:avatar) 14 | 15 | @user = user_class.new 16 | end 17 | 18 | specify "promotion" do 19 | # promote immediately to test that the record is saved before background 20 | # job is kicked off 21 | @user.avatar_attacher.promote_block do |attacher| 22 | record = attacher.record.class.with_pk!(attacher.record.id) 23 | 24 | attacher.class 25 | .retrieve(model: record, name: attacher.name, file: attacher.file_data) 26 | .atomic_promote 27 | end 28 | 29 | @user.avatar = fakeio 30 | @user.save 31 | 32 | assert_equal :cache, @user.avatar.storage_key 33 | 34 | @user.reload 35 | 36 | assert_equal :store, @user.avatar.storage_key 37 | end 38 | 39 | specify "replacing" do 40 | @user.avatar_attacher.destroy_block do |attacher| 41 | @job = Fiber.new do 42 | attacher = attacher.class.from_data(attacher.data) 43 | attacher.destroy 44 | end 45 | end 46 | 47 | @user.avatar = fakeio 48 | @user.save 49 | 50 | previous_file = @user.avatar 51 | 52 | @user.update(avatar: fakeio) 53 | 54 | assert previous_file.exists? 55 | 56 | @job.resume 57 | 58 | refute previous_file.exists? 59 | end 60 | 61 | specify "destroying" do 62 | @user.avatar_attacher.destroy_block do |attacher| 63 | @job = Fiber.new do 64 | attacher = attacher.class.from_data(attacher.data) 65 | attacher.destroy 66 | end 67 | end 68 | 69 | @user.avatar = fakeio 70 | @user.save 71 | @user.destroy 72 | 73 | assert @user.avatar.exists? 74 | 75 | @job.resume 76 | 77 | refute @user.avatar.exists? 78 | end 79 | 80 | specify "destroying during promotion" do 81 | @user.avatar_attacher.promote_block do |attacher| 82 | @job = Fiber.new do 83 | record = attacher.record.class.with_pk!(attacher.record.id) 84 | 85 | attacher.class 86 | .retrieve(model: record, name: attacher.name, file: attacher.file_data) 87 | .atomic_promote 88 | end 89 | end 90 | 91 | @user.avatar = fakeio 92 | @user.save 93 | 94 | @user.destroy # doesn't delete cached file 95 | 96 | assert_raises Sequel::NoMatchingRow do 97 | @job.resume 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/plugin/cached_attachment_data_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/cached_attachment_data" 3 | 4 | describe Shrine::Plugins::CachedAttachmentData do 5 | before do 6 | @attacher = attacher { plugin :cached_attachment_data } 7 | @shrine = @attacher.shrine_class 8 | end 9 | 10 | describe "Attachment" do 11 | before do 12 | @model_class = model_class(:file_data) 13 | end 14 | 15 | describe "#cached__data" do 16 | it "returns cached file data" do 17 | @shrine.plugin :model 18 | @model_class.include @shrine::Attachment.new(:file) 19 | 20 | model = @model_class.new 21 | model.file = fakeio 22 | 23 | assert_equal model.file.to_json, model.cached_file_data 24 | end 25 | 26 | it "is not defined for entity attachments" do 27 | @shrine.plugin :model 28 | @model_class.include @shrine::Attachment.new(:file, model: false) 29 | 30 | refute @model_class.method_defined?(:cached_file_data) 31 | end 32 | end 33 | end 34 | 35 | describe "Attacher" do 36 | describe "#cached_data" do 37 | it "returns data of cached changed file" do 38 | @attacher.attach_cached(fakeio) 39 | assert_equal @attacher.file.to_json, @attacher.cached_data 40 | end 41 | 42 | it "returns nil when changed file is not cached" do 43 | @attacher.attach(fakeio) 44 | assert_nil @attacher.cached_data 45 | end 46 | 47 | it "returns nil when cached file is not changed" do 48 | @attacher.set @shrine.upload(fakeio, :cache) 49 | assert_nil @attacher.cached_data 50 | end 51 | 52 | it "returns nil when no file is attached" do 53 | assert_nil @attacher.cached_data 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/plugin/default_url_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/default_url" 3 | 4 | describe Shrine::Plugins::DefaultUrl do 5 | before do 6 | @attacher = attacher { plugin :default_url } 7 | @shrine = @attacher.shrine_class 8 | end 9 | 10 | describe "Attacher" do 11 | describe "#url" do 12 | it "returns block value when attachment is missing" do 13 | @attacher.class.default_url { "default_url" } 14 | 15 | assert_equal "default_url", @attacher.url 16 | end 17 | 18 | it "returns nil when no block is given and attachment is missing" do 19 | assert_nil @attacher.url 20 | end 21 | 22 | it "returns attachment URL if attachment is present" do 23 | @attacher.class.default_url { "default_url" } 24 | 25 | @attacher.attach(fakeio) 26 | 27 | assert_equal @attacher.file.url, @attacher.url 28 | end 29 | 30 | it "evaluates the block in context of the attacher instance" do 31 | @attacher.class.default_url { to_s } 32 | 33 | assert_equal @attacher.to_s, @attacher.url 34 | end 35 | 36 | it "yields the given URL options to the block" do 37 | @attacher.class.default_url { |**options| options.to_json } 38 | 39 | assert_equal '{"foo":"bar"}', @attacher.url(foo: "bar") 40 | end 41 | 42 | it "accepts :host" do 43 | @shrine.plugin :default_url, host: "https://example.com" 44 | @attacher.class.default_url { "/bar/baz" } 45 | assert_equal "https://example.com/bar/baz", @attacher.url 46 | 47 | @shrine.plugin :default_url, host: "https://example.com/foo" 48 | assert_equal "https://example.com/foo/bar/baz", @attacher.url 49 | end 50 | 51 | it "doesn't override previously set default URL if no block is given" do 52 | @attacher.class.default_url { "default_url" } 53 | @shrine.plugin :default_url 54 | 55 | assert_equal "default_url", @attacher.url 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/plugin/delete_raw_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/delete_raw" 3 | 4 | describe Shrine::Plugins::DeleteRaw do 5 | before do 6 | @uploader = uploader { plugin :delete_raw } 7 | end 8 | 9 | describe "Shrine" do 10 | describe "#upload" do 11 | it "deletes files after upload" do 12 | @uploader.upload(tempfile = Tempfile.new) 13 | refute File.exist?(tempfile.path) 14 | end 15 | 16 | it "deletes tempfiles after upload" do 17 | @uploader.upload(file = File.open(Tempfile.new.path)) 18 | refute File.exist?(file.path) 19 | end 20 | 21 | it "deletes IOs that respond to #path after upload" do 22 | io = FakeIO.new("file") 23 | def io.path; (@tempfile ||= Tempfile.new).path; end 24 | @uploader.upload(io) 25 | refute File.exist?(io.path) 26 | end 27 | 28 | it "doesn't raise an error if file is already deleted" do 29 | tempfile = Tempfile.new 30 | File.delete(tempfile.path) 31 | @uploader.upload(tempfile) 32 | end 33 | 34 | it "doesn't attempt to delete non-files" do 35 | @uploader.upload(fakeio) 36 | end 37 | 38 | it "doesn't attempt to delete UploadedFiles" do 39 | uploaded_file = @uploader.upload(fakeio) 40 | @uploader.upload(uploaded_file) 41 | assert uploaded_file.exists? 42 | end 43 | 44 | it "accepts specifying storages" do 45 | @uploader.class.plugin :delete_raw, storages: [:store] 46 | @uploader.class.new(:cache).upload(tempfile = Tempfile.new) 47 | assert File.exist?(tempfile.path) 48 | @uploader.class.new(:store).upload(tempfile = Tempfile.new) 49 | refute File.exist?(tempfile.path) 50 | end 51 | 52 | it "accepts :delete for skipping deletion" do 53 | file = Tempfile.new 54 | @uploader.upload(file, delete: false) 55 | assert File.exist?(file.path) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/plugin/dynamic_storage_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/dynamic_storage" 3 | 4 | describe Shrine::Plugins::DynamicStorage do 5 | before do 6 | @uploader = uploader { plugin :dynamic_storage } 7 | @shrine = @uploader.class 8 | end 9 | 10 | it "allows registering a storage with a regex" do 11 | @shrine.storage(/store_(\w+)/) { |match| match[1].to_sym } 12 | 13 | assert_equal :foo, @shrine.new(:store_foo).storage 14 | assert_equal :bar, @shrine.new(:store_bar).storage 15 | end 16 | 17 | it "allows saved storage resolvers to be inherited" do 18 | @shrine.storage(/store/) { |match| Shrine::Storage::Memory.new } 19 | subclass = Class.new(@shrine) 20 | refute_equal subclass.storages[:store], subclass.find_storage(:store) 21 | end 22 | 23 | it "doesn't clear registered storage resolvers when reapplying" do 24 | @shrine.storage(/store/) { |match| Shrine::Storage::Memory.new } 25 | @shrine.plugin :dynamic_storage 26 | refute_equal @shrine.storages[:store], @shrine.find_storage(:store) 27 | end 28 | 29 | it "delegates to default behaviour when storage wasn't found" do 30 | assert_instance_of Shrine::Storage::Memory, @uploader.class.new(:store).storage 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/plugin/included_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/included" 3 | 4 | describe Shrine::Plugins::Included do 5 | it "enables extending the model attachment is being included to" do 6 | @uploader = uploader do 7 | plugin :included do |name| 8 | define_method("#{name}_foo") {} 9 | end 10 | end 11 | 12 | model = Class.new 13 | model.include @uploader.class::Attachment.new(:avatar) 14 | assert_respond_to model.new, :avatar_foo 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/plugin/keep_files_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/keep_files" 3 | 4 | describe Shrine::Plugins::KeepFiles do 5 | before do 6 | @attacher = attacher { plugin :keep_files } 7 | @shrine = @attacher.shrine_class 8 | end 9 | 10 | describe "Attacher" do 11 | describe "#destroy_attached" do 12 | it "keeps files" do 13 | @attacher.attach(fakeio) 14 | @attacher.destroy_attached 15 | 16 | assert @attacher.file.exists? 17 | end 18 | 19 | it "doesn't spawn a background job" do 20 | @shrine.plugin :backgrounding 21 | @attacher.destroy_block { @called = true } 22 | 23 | @attacher.attach(fakeio) 24 | @attacher.destroy_attached 25 | 26 | assert @attacher.file.exists? 27 | refute @called 28 | end 29 | end 30 | 31 | describe "#destroy_previous" do 32 | it "keep files" do 33 | previous_file = @attacher.attach(fakeio) 34 | @attacher.attach(fakeio) 35 | @attacher.destroy_previous 36 | 37 | assert previous_file.exists? 38 | end 39 | 40 | it "doesn't spawn a background job" do 41 | @shrine.plugin :backgrounding 42 | @attacher.destroy_block { @called = true } 43 | 44 | previous_file = @attacher.attach(fakeio) 45 | @attacher.attach(fakeio) 46 | @attacher.destroy_previous 47 | 48 | assert previous_file.exists? 49 | refute @called 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/plugin/module_include_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/module_include" 3 | 4 | describe Shrine::Plugins::ModuleInclude do 5 | before do 6 | @shrine = shrine { plugin :module_include } 7 | end 8 | 9 | describe "Shrine" do 10 | describe ".attachment_module" do 11 | it "includes module into Attachment" do 12 | @shrine.attachment_module { def one; end } 13 | @shrine.attachment_module Module.new { def two; end } 14 | assert_includes @shrine::Attachment.instance_methods, :one 15 | assert_includes @shrine::Attachment.instance_methods, :two 16 | end 17 | end 18 | 19 | describe ".attacher_module" do 20 | it "includes module into Attacher" do 21 | @shrine.attacher_module { def one; end } 22 | @shrine.attacher_module Module.new { def two; end } 23 | assert_includes @shrine::Attacher.instance_methods, :one 24 | assert_includes @shrine::Attacher.instance_methods, :two 25 | end 26 | end 27 | 28 | describe ".file_module" do 29 | it "includes module into UploadedFile" do 30 | @shrine.file_module { def one; end } 31 | @shrine.file_module Module.new { def two; end } 32 | assert_includes @shrine::UploadedFile.instance_methods, :one 33 | assert_includes @shrine::UploadedFile.instance_methods, :two 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/plugin/multi_cache_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/multi_cache" 3 | 4 | describe Shrine::Plugins::MultiCache do 5 | before do 6 | @attacher = attacher { plugin :multi_cache, additional_cache: [:other_cache] } 7 | @shrine = @attacher.shrine_class 8 | end 9 | 10 | describe "Attacher" do 11 | describe "#attach_cached" do 12 | it "allows attaching files uploaded to additional cache" do 13 | file = @attacher.upload(fakeio, :other_cache) 14 | 15 | @attacher.attach_cached(file.data) 16 | 17 | assert_equal :other_cache, @attacher.file.storage_key 18 | assert @attacher.cached? 19 | end 20 | 21 | it "can still attach files uploaded to primary cache" do 22 | file = @attacher.upload(fakeio, :cache) 23 | 24 | @attacher.attach_cached(file.data) 25 | 26 | assert_equal :cache, @attacher.file.storage_key 27 | assert @attacher.cached? 28 | end 29 | 30 | it "still uploads files to primary cache" do 31 | @attacher.attach_cached(fakeio) 32 | 33 | assert_equal :cache, @attacher.file.storage_key 34 | assert @attacher.cached? 35 | end 36 | end 37 | 38 | describe "#promote_cached" do 39 | it "promotes files uploaded to additional cache" do 40 | file = @attacher.upload(fakeio, :other_cache) 41 | 42 | @attacher.attach_cached(file.data) 43 | @attacher.promote_cached 44 | 45 | assert_equal :store, @attacher.file.storage_key 46 | assert @attacher.stored? 47 | end 48 | 49 | it "promotes files uploaded to primary cache" do 50 | file = @attacher.upload(fakeio, :cache) 51 | 52 | @attacher.attach_cached(file.data) 53 | @attacher.promote_cached 54 | 55 | assert_equal :store, @attacher.file.storage_key 56 | assert @attacher.stored? 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/plugin/processing_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/processing" 3 | 4 | describe Shrine::Plugins::Processing do 5 | before do 6 | @uploader = uploader { plugin :processing } 7 | @shrine = @uploader.class 8 | end 9 | 10 | describe "Shrine" do 11 | describe "#upload" do 12 | it "executes defined processing" do 13 | @shrine.process(:foo) { |io, **| FakeIO.new(io.read.reverse) } 14 | file = @uploader.upload(fakeio("file"), action: :foo) 15 | assert_equal "elif", file.read 16 | end 17 | 18 | it "executes in context of uploader, and passes right variables" do 19 | minitest = self 20 | 21 | @shrine.process(:foo) do |io, **options| 22 | minitest.assert_kind_of Shrine, self 23 | minitest.assert_respond_to io, :read 24 | minitest.assert_equal :foo, options[:action] 25 | 26 | FakeIO.new(io.read.reverse) 27 | end 28 | 29 | @uploader.upload(fakeio, action: :foo) 30 | end 31 | 32 | it "executes all defined blocks where output of previous is input to next" do 33 | @shrine.process(:foo) { |io, **| FakeIO.new("changed") } 34 | @shrine.process(:foo) { |io, **| FakeIO.new(io.read.reverse) } 35 | 36 | file = @uploader.upload(fakeio, action: :foo) 37 | 38 | assert_equal "degnahc", file.read 39 | end 40 | 41 | it "allows blocks to return nil" do 42 | @shrine.process(:foo) { |io, **| nil } 43 | @shrine.process(:foo) { |io, **| FakeIO.new(io.read.reverse) } 44 | 45 | file = @uploader.upload(fakeio("file"), action: :foo) 46 | 47 | assert_equal "elif", file.read 48 | end 49 | 50 | it "executes defined blocks only if actions match" do 51 | @shrine.process(:foo) { |io, **| FakeIO.new(io.read.reverse) } 52 | 53 | file = @uploader.upload(fakeio("file")) 54 | 55 | assert_equal "file", file.read 56 | end 57 | 58 | it "doesn't overwrite existing definitions when loading the plugin" do 59 | @shrine.process(:foo) { |io, **| FakeIO.new("processed") } 60 | @shrine.plugin :processing 61 | 62 | file = @uploader.upload(fakeio, action: :foo) 63 | 64 | assert_equal "processed", file.read 65 | end 66 | 67 | it "copies processing definitions on subclassing" do 68 | @shrine.process(:foo) { |io, **| FakeIO.new("#{io.read} once") } 69 | 70 | subclass = Class.new(@shrine) 71 | subclass.process(:foo) { |io, **| FakeIO.new("#{io.read} twice") } 72 | 73 | uploaded_by_parent = @uploader.upload(fakeio("file"), action: :foo) 74 | uploaded_by_subclass = subclass.upload(fakeio("file"), :store, action: :foo) 75 | 76 | refute_equal @shrine.opts[:processing], subclass.opts[:processing] 77 | assert_equal 1, @shrine.opts[:processing][:foo].size 78 | assert_equal 2, subclass.opts[:processing][:foo].size 79 | 80 | assert_equal "file once", uploaded_by_parent.read 81 | assert_equal "file once twice", uploaded_by_subclass.read 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/plugin/rack_file_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/rack_file" 3 | require "tempfile" 4 | 5 | describe Shrine::Plugins::RackFile do 6 | before do 7 | @attacher = attacher { plugin :rack_file } 8 | @shrine = @attacher.shrine_class 9 | @rack_hash = { 10 | name: "file", 11 | tempfile: Tempfile.new, 12 | filename: "image.jpg", 13 | type: "image/jpeg", 14 | head: "...", 15 | } 16 | end 17 | 18 | it "allows converting Rack file hash into an IO object" do 19 | rack_file = @shrine.rack_file(@rack_hash) 20 | 21 | assert_equal "", rack_file.read 22 | assert_equal 0, rack_file.size 23 | assert_equal true, rack_file.eof? 24 | 25 | rack_file.rewind 26 | rack_file.close 27 | end 28 | 29 | it "exposes metadata on the IO object" do 30 | rack_file = @shrine.rack_file(@rack_hash) 31 | 32 | assert_equal 0, rack_file.size 33 | assert_equal "image.jpg", rack_file.original_filename 34 | assert_equal "image/jpeg", rack_file.content_type 35 | end 36 | 37 | it "adds methods for accessing the Tempfile" do 38 | rack_file = @shrine.rack_file(@rack_hash) 39 | 40 | assert_equal @rack_hash[:tempfile], rack_file.tempfile 41 | assert_equal @rack_hash[:tempfile], rack_file.to_io 42 | assert_equal @rack_hash[:tempfile].path, rack_file.path 43 | end 44 | 45 | it "accepts hash-like parameters" do 46 | hash_like_class = Class.new(Hash) do 47 | def initialize(hash) 48 | @hash = hash.inject({}) { |h, (key, value)| h.update(key.to_s => value) } 49 | end 50 | 51 | def key?(name) 52 | @hash.key?(name.to_s) 53 | end 54 | 55 | def [](name) 56 | @hash[name.to_s] 57 | end 58 | end 59 | 60 | rack_file = @shrine.rack_file(hash_like_class.new(@rack_hash)) 61 | 62 | assert_equal 0, rack_file.size 63 | assert_equal "image.jpg", rack_file.original_filename 64 | assert_equal "image/jpeg", rack_file.content_type 65 | end 66 | 67 | it "converts filename from binary encoding to utf-8" do 68 | @rack_hash[:filename] = "über_pdf_with_1337%_leetness.pdf".b 69 | 70 | rack_file = @shrine.rack_file(@rack_hash) 71 | 72 | assert_equal Encoding::UTF_8, rack_file.original_filename.encoding 73 | assert_equal Encoding::BINARY, @rack_hash[:filename].encoding 74 | end 75 | 76 | it "supports assigning Rack files directly" do 77 | @attacher.assign(@rack_hash) 78 | 79 | assert_equal "", @attacher.get.read 80 | assert_equal 0, @attacher.get.size 81 | assert_equal "image.jpg", @attacher.get.original_filename 82 | assert_equal "image/jpeg", @attacher.get.mime_type 83 | end 84 | 85 | it "accepts assign options" do 86 | @attacher.assign(@rack_hash, metadata: { "foo" => "bar" }) 87 | assert_equal "bar", @attacher.file.metadata["foo"] 88 | 89 | @attacher.assign(fakeio, metadata: { "foo" => "bar" }) 90 | assert_equal "bar", @attacher.file.metadata["foo"] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/plugin/recache_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/recache" 3 | 4 | describe Shrine::Plugins::Recache do 5 | before do 6 | @attacher = attacher { plugin :recache } 7 | end 8 | 9 | it "recaches cached files" do 10 | file = @attacher.assign(fakeio("original")) 11 | 12 | @attacher.save 13 | 14 | assert_equal :cache, @attacher.file.storage_key 15 | refute_equal file, @attacher.file 16 | assert_equal "original", @attacher.file.read 17 | end 18 | 19 | it "doesn't recache if attachment is missing" do 20 | @attacher.save 21 | end 22 | 23 | it "recaches only cached files" do 24 | file = @attacher.attach(fakeio) 25 | 26 | @attacher.save 27 | 28 | assert_equal file, @attacher.file 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/plugin/restore_cached_data_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/restore_cached_data" 3 | 4 | describe Shrine::Plugins::RestoreCachedData do 5 | before do 6 | @attacher = attacher { plugin :restore_cached_data } 7 | @shrine = @attacher.shrine_class 8 | end 9 | 10 | describe "Attacher" do 11 | describe "#assign" do 12 | it "doesn't reextract metadata of current cached file" do 13 | file = @attacher.attach_cached(fakeio, metadata: false) 14 | 15 | @shrine.any_instance.expects(:extract_metadata).never 16 | 17 | @attacher.assign({ id: file.id, storage: file.storage_key }) 18 | end 19 | 20 | it "doesn't reextract metadata of current stored file" do 21 | file = @attacher.attach(fakeio, metadata: false) 22 | 23 | @shrine.any_instance.expects(:extract_metadata).never 24 | 25 | @attacher.assign({ id: file.id, storage: file.storage_key }) 26 | end 27 | end 28 | 29 | describe "#attach_cached" do 30 | it "reextracts metadata of set cached files" do 31 | cached_file = @attacher.upload(fakeio("a" * 1024), :cache) 32 | cached_file.metadata["size"] = 5 33 | 34 | @attacher.attach_cached(cached_file.data) 35 | 36 | assert_equal 1024, @attacher.file.metadata["size"] 37 | end 38 | 39 | it "skips extracting if the file is not cached" do 40 | stored_file = @attacher.upload(fakeio, :store) 41 | 42 | @shrine.any_instance.expects(:extract_metadata).never 43 | 44 | assert_raises(Shrine::Error) do 45 | @attacher.attach_cached(stored_file.data) 46 | end 47 | end 48 | 49 | it "forwards options to uploader" do 50 | cached_file = @attacher.upload(fakeio, :cache) 51 | 52 | @shrine.plugin :add_metadata 53 | metadata_options = nil 54 | @shrine.add_metadata(:foo) { |io, options| metadata_options = options } 55 | 56 | @attacher.attach_cached(cached_file.data, foo: "bar") 57 | 58 | assert_equal "bar", metadata_options[:foo] 59 | end 60 | 61 | it "fowards attacher context to uploader" do 62 | @shrine.plugin :add_metadata 63 | metadata_options = nil 64 | @shrine.add_metadata(:foo) { |io, options| metadata_options = options } 65 | 66 | cached_file = @attacher.upload(fakeio, :cache) 67 | @attacher.context.merge!(foo: "bar") 68 | @attacher.attach_cached(cached_file.data) 69 | 70 | assert_equal "bar", metadata_options[:foo] 71 | end 72 | 73 | it "works with versions plugin" do 74 | @shrine.plugin :versions 75 | cached_file = @attacher.upload(fakeio("a" * 1024), :cache) 76 | cached_file.metadata["size"] = 5 77 | 78 | @attacher.attach_cached({ "version" => cached_file.data }) 79 | 80 | assert_equal 1024, @attacher.file[:version].metadata["size"] 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/plugin/tempfile_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/tempfile" 3 | 4 | describe Shrine::Plugins::Tempfile do 5 | before do 6 | @uploader = uploader { plugin :tempfile } 7 | @shrine = @uploader.class 8 | end 9 | 10 | it "downloads the content to tempfile on first call" do 11 | uploaded_file = @uploader.upload(fakeio("content")) 12 | uploaded_file.open do 13 | tempfile = uploaded_file.tempfile 14 | assert_instance_of Tempfile, tempfile 15 | assert_equal "content", tempfile.read 16 | end 17 | end 18 | 19 | it "returns the same tempfile on subsequent calls" do 20 | uploaded_file = @uploader.upload(fakeio("content")) 21 | uploaded_file.open do 22 | assert_equal uploaded_file.tempfile, uploaded_file.tempfile 23 | end 24 | end 25 | 26 | it "deletes the tempfile at the end of the open block" do 27 | uploaded_file = @uploader.upload(fakeio("content")) 28 | tempfile = uploaded_file.open { uploaded_file.tempfile } 29 | assert tempfile.closed? 30 | assert_nil tempfile.path 31 | end 32 | 33 | it "deletes the tempfile when uploaded file is closed explicitly" do 34 | uploaded_file = @uploader.upload(fakeio("content")) 35 | uploaded_file.open 36 | tempfile = uploaded_file.tempfile 37 | uploaded_file.close 38 | assert tempfile.closed? 39 | assert_nil tempfile.path 40 | end 41 | 42 | it "rewinds the tempfile" do 43 | uploaded_file = @uploader.upload(fakeio("content")) 44 | uploaded_file.open do 45 | uploaded_file.tempfile.read 46 | assert_equal 0, uploaded_file.tempfile.pos 47 | end 48 | end 49 | 50 | it "raises an error when uploaded file is not open" do 51 | uploaded_file = @uploader.upload(fakeio("content")) 52 | assert_raises(Shrine::Error) { uploaded_file.tempfile } 53 | end 54 | 55 | it "allows caching the tempfile again" do 56 | uploaded_file = @uploader.upload(fakeio("content")) 57 | uploaded_file.open do 58 | assert_equal "content", uploaded_file.tempfile.read 59 | end 60 | uploaded_file.open do 61 | assert_equal "content", uploaded_file.tempfile.read 62 | end 63 | end 64 | 65 | describe "Shrine.with_file" do 66 | it "yields an open tempfile reference" do 67 | uploaded_file = @uploader.upload(fakeio("content")) 68 | uploaded_file.open do 69 | file = @shrine.with_file(uploaded_file) do |file| 70 | assert_equal uploaded_file.tempfile.path, file.path 71 | refute_equal uploaded_file.tempfile.fileno, file.fileno 72 | assert file.binmode? 73 | refute file.closed? 74 | file 75 | end 76 | assert file.closed? 77 | end 78 | 79 | # calls #download when uploaded file is not opened 80 | path1, path2 = nil 81 | @shrine.with_file(uploaded_file) { |file| path1 = file.path } 82 | @shrine.with_file(uploaded_file) { |file| path2 = file.path } 83 | refute_equal path1, path2 84 | end 85 | 86 | it "works with non-opened uploaded files" do 87 | uploaded_file = @uploader.upload(fakeio("content")) 88 | @shrine.with_file(uploaded_file) do |file| 89 | File.exist?(file.path) 90 | assert_equal "content", file.read 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/plugin/upload_options_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/upload_options" 3 | 4 | describe Shrine::Plugins::UploadOptions do 5 | before do 6 | @uploader = uploader(:store) { plugin :upload_options } 7 | @shrine = @uploader.class 8 | end 9 | 10 | it "accepts a block" do 11 | @shrine.plugin :upload_options, store: -> (io, _) { Hash[foo: "foo"] } 12 | @uploader.storage.expects(:upload).with { |*, o| o[:foo] == "foo" } 13 | @uploader.upload(fakeio) 14 | end 15 | 16 | it "accepts a hash" do 17 | @shrine.plugin :upload_options, store: { foo: "foo" } 18 | @uploader.storage.expects(:upload).with { |*, o| o[:foo] == "foo" } 19 | @uploader.upload(fakeio) 20 | end 21 | 22 | it "only passes upload options to specified storages" do 23 | @shrine.plugin :upload_options, cache: { foo: "foo" } 24 | @uploader.storage.expects(:upload).with { |*, o| !o.key?(:foo) } 25 | @uploader.upload(fakeio) 26 | end 27 | 28 | it "takes lower precedence than :upload_options" do 29 | @shrine.plugin :upload_options, cache: { foo: "foo" } 30 | @uploader.storage.expects(:upload).with { |*, o| o[:foo] == "bar" } 31 | @uploader.upload(fakeio, upload_options: { foo: "bar" }) 32 | end 33 | 34 | it "doesn't overwrite existing options when loading the plugin" do 35 | @shrine.plugin :upload_options, cache: { foo: "foo" } 36 | @shrine.plugin :upload_options, store: { bar: "bar" } 37 | assert_equal Hash[cache: { foo: "foo" }, store: { bar: "bar" }], @shrine.opts[:upload_options] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/plugin/url_options_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/plugins/url_options" 3 | 4 | describe Shrine::Plugins::UrlOptions do 5 | before do 6 | @uploader = uploader { plugin :url_options } 7 | @shrine = @uploader.class 8 | end 9 | 10 | describe "UploadedFile" do 11 | describe "#url" do 12 | it "adds default options statically" do 13 | @shrine.plugin :url_options, store: { foo: "foo" } 14 | 15 | file = @uploader.upload(fakeio) 16 | file.storage.expects(:url).with(file.id, { foo: "foo" }) 17 | file.url 18 | end 19 | 20 | it "adds default options dynamically" do 21 | minitest = self 22 | 23 | @uploader.class.plugin :url_options, store: -> (io, options) do 24 | minitest.assert_kind_of Shrine::UploadedFile, io 25 | minitest.assert_equal "bar", options[:bar] 26 | 27 | { foo: "foo" } 28 | end 29 | 30 | file = @uploader.upload(fakeio) 31 | file.storage.expects(:url).with(file.id, { foo: "foo", bar: "bar" }) 32 | file.url(bar: "bar") 33 | end 34 | 35 | it "merges default options with direct options" do 36 | @shrine.plugin :url_options, store: { foo: "foo" } 37 | 38 | file = @uploader.upload(fakeio) 39 | file.storage.expects(:url).with(file.id, { foo: "foo", bar: "bar" }) 40 | file.url(bar: "bar") 41 | end 42 | 43 | it "allows direct options to override default options" do 44 | @shrine.plugin :url_options, store: { foo: "foo" } 45 | 46 | file = @uploader.upload(fakeio) 47 | file.storage.expects(:url).with(file.id, { foo: "overriden" }) 48 | file.url(foo: "overriden") 49 | end 50 | 51 | it "handles nil values" do 52 | @shrine.plugin :url_options, store: nil 53 | 54 | file = @uploader.upload(fakeio) 55 | file.storage.expects(:url).with(file.id, { foo: "foo" }) 56 | file.url(foo: "foo") 57 | 58 | @shrine.plugin :url_options, store: -> (io, options) {} 59 | 60 | file = @uploader.upload(fakeio) 61 | file.storage.expects(:url).with(file.id, { foo: "foo" }) 62 | file.url(foo: "foo") 63 | end 64 | 65 | it "allows overriding passed options" do 66 | @shrine.plugin :url_options, store: -> (io, options) { 67 | { foo: "#{options.delete(:foo)} bar" } 68 | } 69 | 70 | file = @uploader.upload(fakeio) 71 | file.storage.expects(:url).with(file.id, { foo: "foo bar" }) 72 | file.url(foo: "foo") 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/plugin_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "plugin system" do 4 | describe "Shrine.plugin" do 5 | describe "when called globally" do 6 | before do 7 | @components = [Shrine, Shrine::UploadedFile, Shrine::Attachment, Shrine::Attacher] 8 | 9 | @components.each do |component| 10 | component::InstanceMethods.send(:define_method, :foo) { :foo } 11 | component::ClassMethods.send(:define_method, :foo) { :foo } 12 | end 13 | 14 | module TestPlugin 15 | module ClassMethods; def foo; :plugin_foo; end; end 16 | module InstanceMethods; def foo; :plugin_foo; end; end 17 | module FileClassMethods; def foo; :plugin_foo; end; end 18 | module FileMethods; def foo; :plugin_foo; end; end 19 | module AttacherClassMethods; def foo; :plugin_foo; end; end 20 | module AttacherMethods; def foo; :plugin_foo; end; end 21 | module AttachmentClassMethods; def foo; :plugin_foo; end; end 22 | module AttachmentMethods; def foo; :plugin_foo; end; end 23 | end 24 | end 25 | 26 | after do 27 | @components.each do |component| 28 | component::InstanceMethods.send(:undef_method, :foo) 29 | component::ClassMethods.send(:undef_method, :foo) 30 | end 31 | 32 | TestPlugin.constants.each do |name| 33 | mod = TestPlugin.const_get(name) 34 | mod.send(:undef_method, :foo) 35 | end 36 | end 37 | 38 | it "allows the plugin to override base methods of core classes" do 39 | assert_equal :foo, Shrine.foo 40 | assert_equal :foo, Shrine.allocate.foo 41 | assert_equal :foo, Shrine::UploadedFile.foo 42 | assert_equal :foo, Shrine::UploadedFile.allocate.foo 43 | assert_equal :foo, Shrine::Attacher.foo 44 | assert_equal :foo, Shrine::Attacher.allocate.foo 45 | assert_equal :foo, Shrine::Attachment.foo 46 | assert_equal :foo, Shrine::Attachment.new(:bar).foo 47 | 48 | Shrine.plugin TestPlugin 49 | 50 | assert_equal :plugin_foo, Shrine.foo 51 | assert_equal :plugin_foo, Shrine.allocate.foo 52 | assert_equal :plugin_foo, Shrine::UploadedFile.foo 53 | assert_equal :plugin_foo, Shrine::UploadedFile.allocate.foo 54 | assert_equal :plugin_foo, Shrine::Attacher.foo 55 | assert_equal :plugin_foo, Shrine::Attacher.allocate.foo 56 | assert_equal :plugin_foo, Shrine::Attachment.foo 57 | assert_equal :plugin_foo, Shrine::Attachment.new(:bar).foo 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/storage/memory_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "shrine/storage/s3" 3 | require "shrine/storage/linter" 4 | 5 | describe Shrine::Storage::Memory do 6 | before do 7 | @memory = Shrine::Storage::Memory.new 8 | end 9 | 10 | it "passes the linter" do 11 | assert Shrine::Storage::Linter.call(@memory) 12 | end 13 | 14 | describe "#upload" do 15 | it "accepts keyword arguments" do 16 | @memory.upload(fakeio, "key", foo: "bar") 17 | end 18 | end 19 | 20 | describe "#open" do 21 | it "accepts keyword arguments" do 22 | @memory.upload(fakeio, "key") 23 | @memory.open("key", foo: "bar") 24 | end 25 | end 26 | 27 | # work around apparent bug in ruby 2.7.0 28 | # https://bugs.ruby-lang.org/issues/16497 29 | it "preserves encoding despite Encoding.default_internal set" do 30 | @memory.upload(fakeio("content".b), "key") 31 | 32 | begin 33 | original_internal = Encoding.default_internal 34 | Encoding.default_internal = Encoding::UTF_8 35 | 36 | assert_equal Encoding::BINARY, @memory.open("key").read.encoding 37 | ensure 38 | Encoding.default_internal = original_internal 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/activerecord.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | ActiveRecord::Base.establish_connection( 4 | adapter: "sqlite3", 5 | database: ":memory:", 6 | ) 7 | 8 | ActiveRecord::Base.connection.create_table(:users) do |t| 9 | t.string :name 10 | t.text :avatar_data 11 | end 12 | -------------------------------------------------------------------------------- /test/support/fakeio.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "stringio" 3 | 4 | class FakeIO 5 | attr_reader :original_filename, :content_type 6 | 7 | def initialize(content, filename: nil, content_type: nil) 8 | @io = StringIO.new(content) 9 | @original_filename = filename 10 | @content_type = content_type 11 | end 12 | 13 | extend Forwardable 14 | delegate %i[read rewind eof? close size] => :@io 15 | end 16 | -------------------------------------------------------------------------------- /test/support/file_helper.rb: -------------------------------------------------------------------------------- 1 | require "./test/support/fakeio" 2 | 3 | module FileHelper 4 | extend self 5 | 6 | def fakeio(content = "file", **options) 7 | FakeIO.new(content, **options) 8 | end 9 | 10 | def image 11 | File.open("test/fixtures/image.jpg", binmode: true) 12 | end 13 | 14 | def tempfile(content, basename = "") 15 | tempfile = Tempfile.new(basename, binmode: true) 16 | tempfile.write(content) 17 | tempfile.tap(&:open) 18 | end 19 | end 20 | 21 | Minitest::Test.include FileHelper 22 | -------------------------------------------------------------------------------- /test/support/logging_helper.rb: -------------------------------------------------------------------------------- 1 | require "stringio" 2 | 3 | module LoggingHelper 4 | def assert_logged(pattern) 5 | result = nil 6 | logged = capture_logged { result = yield } 7 | 8 | assert_match pattern, logged 9 | 10 | result 11 | end 12 | 13 | def refute_logged(pattern) 14 | result = nil 15 | logged = capture_logged { result = yield } 16 | 17 | refute_match pattern, logged 18 | 19 | result 20 | end 21 | 22 | def capture_logged 23 | previous_logger = Shrine.logger 24 | output = StringIO.new 25 | Shrine.logger = Logger.new(output) 26 | Shrine.logger.formatter = -> (*, message) { "#{message}\n" } 27 | 28 | yield 29 | 30 | output.string 31 | ensure 32 | Shrine.logger = previous_logger 33 | end 34 | end 35 | 36 | Minitest::Test.include LoggingHelper 37 | -------------------------------------------------------------------------------- /test/support/sequel.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | 3 | DB = Sequel.connect("#{"jdbc:" if RUBY_ENGINE == "jruby"}sqlite::memory:") 4 | 5 | DB.create_table :users do 6 | primary_key :id 7 | String :name 8 | String :avatar_data 9 | end 10 | -------------------------------------------------------------------------------- /test/support/shrine_helper.rb: -------------------------------------------------------------------------------- 1 | require "shrine/storage/memory" 2 | 3 | module ShrineHelper 4 | def shrine(&block) 5 | uploader_class = Class.new(Shrine) 6 | uploader_class.storages[:cache] = Shrine::Storage::Memory.new 7 | uploader_class.storages[:other_cache] = Shrine::Storage::Memory.new 8 | uploader_class.storages[:store] = Shrine::Storage::Memory.new 9 | uploader_class.storages[:other_store] = Shrine::Storage::Memory.new 10 | uploader_class.class_eval(&block) if block 11 | uploader_class 12 | end 13 | 14 | def uploader(storage_key = :store, &block) 15 | uploader_class = shrine(&block) 16 | uploader_class.new(storage_key) 17 | end 18 | 19 | def attacher(**options, &block) 20 | shrine = shrine(&block) 21 | shrine::Attacher.new(**options) 22 | end 23 | 24 | def model_class(*attributes) 25 | klass = entity_class(*attributes) 26 | klass.attr_writer *attributes 27 | klass 28 | end 29 | 30 | def entity_class(*attributes) 31 | klass = Class.new(Struct) 32 | klass.attr_reader *attributes 33 | klass 34 | end 35 | 36 | def entity(attributes) 37 | entity_class = entity_class(*attributes.keys) 38 | entity_class.new(attributes) 39 | end 40 | 41 | def model(attributes) 42 | model_class = model_class(*attributes.keys) 43 | model_class.new(attributes) 44 | end 45 | 46 | class Struct 47 | # These are private on Ruby 2.4 and older, so we make them public. 48 | def self.attr_reader(*names) super(*names) end 49 | def self.attr_writer(*names) super(*names) end 50 | 51 | def initialize(attributes = {}) 52 | attributes.each do |name, value| 53 | instance_variable_set(:"@#{name}", value) 54 | end 55 | end 56 | end 57 | end 58 | 59 | Minitest::Test.include ShrineHelper 60 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | Warning[:deprecated] = true if RUBY_VERSION >= "2.7" 2 | 3 | require "bundler/setup" 4 | 5 | if ENV["COVERAGE"] 6 | require "simplecov" 7 | SimpleCov.start do 8 | add_filter "/test/" 9 | add_filter "/lib/shrine/storage/linter.rb" 10 | end 11 | end 12 | 13 | ENV["MT_NO_EXPECTATIONS"] = "1" # disable Minitest's expectations monkey-patches 14 | 15 | require "minitest/autorun" 16 | require "minitest/pride" 17 | 18 | # Mocha still references the old constant 19 | MiniTest = Minitest unless defined?(MiniTest) 20 | require "mocha/minitest" 21 | 22 | require "shrine" 23 | 24 | Shrine.logger = Logger.new(nil) # disable mime_type warnings 25 | 26 | Mocha.configure { |config| config.stubbing_non_existent_method = :prevent } 27 | 28 | require "./test/support/shrine_helper" 29 | require "./test/support/file_helper" 30 | require "./test/support/logging_helper" 31 | 32 | class RubySerializer 33 | def self.dump(data) 34 | data.to_s 35 | end 36 | 37 | def self.load(data) 38 | eval(data) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /website/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: "Shrine", 3 | tagline: "File attachment toolkit for Ruby applications", 4 | url: "https://shrinerb.com", 5 | baseUrl: "/", 6 | organizationName: "shrinerb", 7 | projectName: "shrine", 8 | trailingSlash: false, 9 | favicon: "img/favicon.ico", 10 | customFields: { 11 | projectVersion: "3.4.0", 12 | githubUrl: "https://github.com/shrinerb/shrine", 13 | blogUrl: "https://janko.io", 14 | discourseUrl: "https://discourse.shrinerb.com", 15 | githubDiscussionsUrl: "https://github.com/shrinerb/shrine/discussions", 16 | stackOverflowUrl: "https://stackoverflow.com/questions/tagged/shrine" 17 | }, 18 | onBrokenLinks: "log", 19 | onBrokenMarkdownLinks: "log", 20 | presets: [ 21 | [ 22 | "@docusaurus/preset-classic", 23 | { 24 | docs: { 25 | path: "../doc", 26 | showLastUpdateAuthor: true, 27 | showLastUpdateTime: true, 28 | editUrl: "https://github.com/shrinerb/shrine/edit/master/doc/", 29 | sidebarPath: "sidebars.json", 30 | sidebarCollapsed: false, 31 | breadcrumbs: false, 32 | rehypePlugins: [ 33 | [require("rehype-pretty-code"), { theme: "dracula-soft" }] 34 | ] 35 | }, 36 | pages: { 37 | rehypePlugins: [ 38 | [require("rehype-pretty-code"), { theme: "dracula-soft" }] 39 | ] 40 | }, 41 | theme: { 42 | customCss: "src/css/customTheme.css" 43 | } 44 | } 45 | ] 46 | ], 47 | plugins: [], 48 | themeConfig: { 49 | navbar: { 50 | title: "Shrine", 51 | style: "primary", 52 | logo: { 53 | src: "img/logo.png", 54 | alt: "Shrine logo" 55 | }, 56 | items: [ 57 | { 58 | to: "docs/getting-started", 59 | label: "Guides", 60 | position: "left" 61 | }, 62 | { 63 | type: "docSidebar", 64 | sidebarId: "plugins", 65 | label: "Plugins", 66 | position: "left" 67 | }, 68 | { 69 | type: "docSidebar", 70 | sidebarId: "external", 71 | label: "External", 72 | position: "left" 73 | }, 74 | { 75 | type: "docSidebar", 76 | sidebarId: "release_notes", 77 | label: "Release Notes", 78 | position: "left" 79 | }, 80 | { 81 | href: "https://github.com/shrinerb/shrine", 82 | label: "GitHub", 83 | position: "right" 84 | }, 85 | { 86 | href: "https://github.com/shrinerb/shrine/discussions", 87 | label: "Discussion", 88 | position: "right" 89 | }, 90 | { 91 | href: "https://github.com/shrinerb/shrine/wiki", 92 | label: "Wiki", 93 | position: "right" 94 | } 95 | ] 96 | }, 97 | colorMode: { 98 | disableSwitch: true, 99 | }, 100 | image: "img/logo.png", 101 | footer: { 102 | copyright: `Copyright © ${new Date().getFullYear()} Janko Marohnić`, 103 | style: "dark", 104 | }, 105 | algolia: { 106 | appId: "KBFWBJ5DPX", 107 | apiKey: "f00673f90ed23135519e3c40bae05e17", 108 | contextualSearch: false, 109 | indexName: "shrinerb" 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus start", 5 | "build": "docusaurus build", 6 | "publish-gh-pages": "docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version", 10 | "swizzle": "docusaurus swizzle", 11 | "deploy": "docusaurus deploy", 12 | "docusaurus": "docusaurus" 13 | }, 14 | "dependencies": { 15 | "@docusaurus/core": "^2.4.1", 16 | "@docusaurus/preset-classic": "^2.4.1", 17 | "clsx": "^1.1.1", 18 | "react": "^17.0.2", 19 | "react-dom": "^17.0.2", 20 | "rehype-pretty-code": "^0.9.6", 21 | "shiki": "^0.14.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /website/src/css/customTheme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ifm-color-primary-lightest: #D50A13; 3 | --ifm-color-primary-lighter: #BD0911; 4 | --ifm-color-primary-light: #B40910; 5 | --ifm-color-primary: #A4080F; 6 | --ifm-color-primary-dark: #94070D; 7 | --ifm-color-primary-darker: #8B070D; 8 | --ifm-color-primary-darkest: #73060A; 9 | 10 | --ifm-color-danger: var(--ifm-color-primary); 11 | } 12 | 13 | pre { 14 | background: #282A36 !important; 15 | color: #f6f6f4 !important; 16 | } 17 | 18 | pre span { 19 | font-style: normal !important; 20 | } 21 | -------------------------------------------------------------------------------- /website/src/pages/_demo.md: -------------------------------------------------------------------------------- 1 | You can attach files... 2 | 3 | ```rb 4 | class Photo < Sequel::Model 5 | include Shrine::Attachment(:image) 6 | end 7 | ``` 8 | ```rb 9 | photo = Photo.create(image: file) 10 | photo.image #=> # 11 | photo.image_url #=> "https://my-bucket.s3.amazonaws.com/6bfafe89748ee135.jpg" 12 | photo.image.id #=> "6bfafe89748ee135.jpg" 13 | photo.image.storage #=> # 14 | photo.image.metadata #=> { "size" => 749238, "mime_type" => "image/jpeg", "filename" => "nature.jpg" } 15 | ``` 16 | 17 | ...and process them eagerly... 18 | 19 | ```rb 20 | Shrine::Attacher.derivatives :thumbnails do |original| 21 | magick = ImageProcessing::MiniMagick.source(original) 22 | { large: magick.resize_to_limit!(800, 800), 23 | medium: magick.resize_to_limit!(500, 500), 24 | small: magick.resize_to_limit!(300, 300) } 25 | end 26 | ``` 27 | ```rb 28 | photo.image_derivatives #=> 29 | # { large: #, 30 | # medium: #, 31 | # small: # } 32 | ``` 33 | 34 | ...or on-the-fly... 35 | 36 | ```rb 37 | Shrine.derivation :thumbnail do |file, width, height| 38 | magick = ImageProcessing::MiniMagick.source(original) 39 | magick.resize_to_limit!(width.to_i, height.to_i) 40 | end 41 | ``` 42 | ```rb 43 | photo.image.derivation_url(:thumbnail, 600, 400) 44 | #=> ".../thumbnail/600/400/eyJpZCI6ImZvbyIsInN0b3JhZ2UiOiJzdG9yZSJ9?signature=..." 45 | ``` 46 | -------------------------------------------------------------------------------- /website/src/pages/_sponsors.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => { 4 | const heartEmoji = "https://github.githubassets.com/images/icons/emoji/unicode/1f496.png" 5 | 6 | const sponsors = [ 7 | { 8 | "name": "Ventrata", 9 | "avatar": "https://avatars0.githubusercontent.com/u/23163375?s=460&v=4", 10 | "link": "https://ventrata.com/" 11 | }, 12 | { 13 | "name": "Scout APM", 14 | "avatar": "https://avatars2.githubusercontent.com/u/458509?s=460&v=4", 15 | "link": "https://scoutapm.com/" 16 | }, 17 | { 18 | "name": "Fingerprint", 19 | "avatar": "https://avatars1.githubusercontent.com/u/827952?s=460&v=4", 20 | "link": "https://github.com/fingerprint" 21 | }, 22 | { 23 | "name": "Mislav Marohnić", 24 | "avatar": "https://avatars1.githubusercontent.com/u/887?s=460&v=4", 25 | "link": "https://github.com/mislav" 26 | }, 27 | { 28 | "name": "Stanko Krtalić", 29 | "avatar": "https://avatars1.githubusercontent.com/u/1655218?s=400&v=4", 30 | "link": "https://github.com/monorkin" 31 | }, 32 | { 33 | "name": "Maxence", 34 | "avatar": "https://avatars2.githubusercontent.com/u/6090320?s=460&v=4", 35 | "link": "https://github.com/maxence33" 36 | }, 37 | { 38 | "name": "Bryan O'Neal", 39 | "avatar": "https://avatars2.githubusercontent.com/u/13574856?s=460&u=796bc2484b66416c7a6c5c361f66a17e263abc60&v=4", 40 | "link": "https://github.com/1AL" 41 | }, 42 | { 43 | "name": "Benjamin Klotz", 44 | "avatar": "https://avatars3.githubusercontent.com/u/675705?s=460&u=e561fcc1ba934317e6bd8742789211bf4d7cae73&v=4", 45 | "link": "https://github.com/tak1n" 46 | }, 47 | { 48 | "name": "Igor S. Morozov", 49 | "avatar": "https://avatars3.githubusercontent.com/u/887264?s=460&u=2b573c7479a75cb46b23d3613f0302ae85b3aef3&v=4", 50 | "link": "https://github.com/Morozzzko" 51 | }, 52 | { 53 | "name": "Wout", 54 | "avatar": "https://avatars1.githubusercontent.com/u/107324?s=460&u=5ab2e18bf785c061df13c53067cd671e2b74e10a&v=4", 55 | "link": "https://github.com/wout" 56 | }, 57 | ] 58 | 59 | return ( 60 |
    61 |
    62 |

    Sponsors

    63 | 64 |
    65 | {sponsors.map(sponsor => ( 66 | 67 |
    68 | {`${sponsor.name} 69 |
    70 |
    {sponsor.name}
    71 |
    72 |
    73 |
    74 | ))} 75 |
    76 |
    77 |
    78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /website/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Layout from '@theme/Layout'; 3 | import Sponsors from './_sponsors'; 4 | import Demo from './_demo.md'; 5 | 6 | export default () => { 7 | return ( 8 | 9 |
    10 |
    11 | 12 |

    Shrine

    13 |

    File attachment toolkit for Ruby applications

    14 | 25 |
    26 |
    27 | 28 |
    29 |
    30 | 31 |
    32 |
    33 | 34 | 35 |
    36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrinerb/shrine/1ce6da4be0ca62ecd5776f107fcd0d657b773f28/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrinerb/shrine/1ce6da4be0ca62ecd5776f107fcd0d657b773f28/website/static/img/logo.png --------------------------------------------------------------------------------