activestorage 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of activestorage might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +198 -0
- data/MIT-LICENSE +20 -0
- data/README.md +162 -0
- data/app/assets/javascripts/activestorage.js +942 -0
- data/app/controllers/active_storage/base_controller.rb +8 -0
- data/app/controllers/active_storage/blobs_controller.rb +14 -0
- data/app/controllers/active_storage/direct_uploads_controller.rb +23 -0
- data/app/controllers/active_storage/disk_controller.rb +66 -0
- data/app/controllers/active_storage/representations_controller.rb +14 -0
- data/app/controllers/concerns/active_storage/set_blob.rb +16 -0
- data/app/controllers/concerns/active_storage/set_current.rb +15 -0
- data/app/javascript/activestorage/blob_record.js +73 -0
- data/app/javascript/activestorage/blob_upload.js +35 -0
- data/app/javascript/activestorage/direct_upload.js +48 -0
- data/app/javascript/activestorage/direct_upload_controller.js +67 -0
- data/app/javascript/activestorage/direct_uploads_controller.js +50 -0
- data/app/javascript/activestorage/file_checksum.js +53 -0
- data/app/javascript/activestorage/helpers.js +51 -0
- data/app/javascript/activestorage/index.js +11 -0
- data/app/javascript/activestorage/ujs.js +86 -0
- data/app/jobs/active_storage/analyze_job.rb +12 -0
- data/app/jobs/active_storage/base_job.rb +4 -0
- data/app/jobs/active_storage/purge_job.rb +13 -0
- data/app/models/active_storage/attachment.rb +50 -0
- data/app/models/active_storage/blob.rb +278 -0
- data/app/models/active_storage/blob/analyzable.rb +57 -0
- data/app/models/active_storage/blob/identifiable.rb +31 -0
- data/app/models/active_storage/blob/representable.rb +93 -0
- data/app/models/active_storage/current.rb +5 -0
- data/app/models/active_storage/filename.rb +77 -0
- data/app/models/active_storage/preview.rb +89 -0
- data/app/models/active_storage/variant.rb +131 -0
- data/app/models/active_storage/variation.rb +80 -0
- data/config/routes.rb +32 -0
- data/db/migrate/20170806125915_create_active_storage_tables.rb +26 -0
- data/db/update_migrate/20180723000244_add_foreign_key_constraint_to_active_storage_attachments_for_blob_id.rb +9 -0
- data/lib/active_storage.rb +73 -0
- data/lib/active_storage/analyzer.rb +38 -0
- data/lib/active_storage/analyzer/image_analyzer.rb +52 -0
- data/lib/active_storage/analyzer/null_analyzer.rb +13 -0
- data/lib/active_storage/analyzer/video_analyzer.rb +118 -0
- data/lib/active_storage/attached.rb +25 -0
- data/lib/active_storage/attached/changes.rb +16 -0
- data/lib/active_storage/attached/changes/create_many.rb +46 -0
- data/lib/active_storage/attached/changes/create_one.rb +69 -0
- data/lib/active_storage/attached/changes/create_one_of_many.rb +10 -0
- data/lib/active_storage/attached/changes/delete_many.rb +27 -0
- data/lib/active_storage/attached/changes/delete_one.rb +19 -0
- data/lib/active_storage/attached/many.rb +65 -0
- data/lib/active_storage/attached/model.rb +147 -0
- data/lib/active_storage/attached/one.rb +79 -0
- data/lib/active_storage/downloader.rb +43 -0
- data/lib/active_storage/downloading.rb +47 -0
- data/lib/active_storage/engine.rb +149 -0
- data/lib/active_storage/errors.rb +26 -0
- data/lib/active_storage/gem_version.rb +17 -0
- data/lib/active_storage/log_subscriber.rb +58 -0
- data/lib/active_storage/previewer.rb +84 -0
- data/lib/active_storage/previewer/mupdf_previewer.rb +36 -0
- data/lib/active_storage/previewer/poppler_pdf_previewer.rb +35 -0
- data/lib/active_storage/previewer/video_previewer.rb +26 -0
- data/lib/active_storage/reflection.rb +64 -0
- data/lib/active_storage/service.rb +141 -0
- data/lib/active_storage/service/azure_storage_service.rb +165 -0
- data/lib/active_storage/service/configurator.rb +34 -0
- data/lib/active_storage/service/disk_service.rb +166 -0
- data/lib/active_storage/service/gcs_service.rb +141 -0
- data/lib/active_storage/service/mirror_service.rb +55 -0
- data/lib/active_storage/service/s3_service.rb +116 -0
- data/lib/active_storage/transformers/image_processing_transformer.rb +39 -0
- data/lib/active_storage/transformers/mini_magick_transformer.rb +38 -0
- data/lib/active_storage/transformers/transformer.rb +42 -0
- data/lib/active_storage/version.rb +10 -0
- data/lib/tasks/activestorage.rake +22 -0
- metadata +174 -0
| @@ -0,0 +1,166 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "fileutils"
         | 
| 4 | 
            +
            require "pathname"
         | 
| 5 | 
            +
            require "digest/md5"
         | 
| 6 | 
            +
            require "active_support/core_ext/numeric/bytes"
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            module ActiveStorage
         | 
| 9 | 
            +
              # Wraps a local disk path as an Active Storage service. See ActiveStorage::Service for the generic API
         | 
| 10 | 
            +
              # documentation that applies to all services.
         | 
| 11 | 
            +
              class Service::DiskService < Service
         | 
| 12 | 
            +
                attr_reader :root
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                def initialize(root:)
         | 
| 15 | 
            +
                  @root = root
         | 
| 16 | 
            +
                end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                def upload(key, io, checksum: nil, **)
         | 
| 19 | 
            +
                  instrument :upload, key: key, checksum: checksum do
         | 
| 20 | 
            +
                    IO.copy_stream(io, make_path_for(key))
         | 
| 21 | 
            +
                    ensure_integrity_of(key, checksum) if checksum
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def download(key, &block)
         | 
| 26 | 
            +
                  if block_given?
         | 
| 27 | 
            +
                    instrument :streaming_download, key: key do
         | 
| 28 | 
            +
                      stream key, &block
         | 
| 29 | 
            +
                    end
         | 
| 30 | 
            +
                  else
         | 
| 31 | 
            +
                    instrument :download, key: key do
         | 
| 32 | 
            +
                      File.binread path_for(key)
         | 
| 33 | 
            +
                    rescue Errno::ENOENT
         | 
| 34 | 
            +
                      raise ActiveStorage::FileNotFoundError
         | 
| 35 | 
            +
                    end
         | 
| 36 | 
            +
                  end
         | 
| 37 | 
            +
                end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                def download_chunk(key, range)
         | 
| 40 | 
            +
                  instrument :download_chunk, key: key, range: range do
         | 
| 41 | 
            +
                    File.open(path_for(key), "rb") do |file|
         | 
| 42 | 
            +
                      file.seek range.begin
         | 
| 43 | 
            +
                      file.read range.size
         | 
| 44 | 
            +
                    end
         | 
| 45 | 
            +
                  rescue Errno::ENOENT
         | 
| 46 | 
            +
                    raise ActiveStorage::FileNotFoundError
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
                end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                def delete(key)
         | 
| 51 | 
            +
                  instrument :delete, key: key do
         | 
| 52 | 
            +
                    File.delete path_for(key)
         | 
| 53 | 
            +
                  rescue Errno::ENOENT
         | 
| 54 | 
            +
                    # Ignore files already deleted
         | 
| 55 | 
            +
                  end
         | 
| 56 | 
            +
                end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                def delete_prefixed(prefix)
         | 
| 59 | 
            +
                  instrument :delete_prefixed, prefix: prefix do
         | 
| 60 | 
            +
                    Dir.glob(path_for("#{prefix}*")).each do |path|
         | 
| 61 | 
            +
                      FileUtils.rm_rf(path)
         | 
| 62 | 
            +
                    end
         | 
| 63 | 
            +
                  end
         | 
| 64 | 
            +
                end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                def exist?(key)
         | 
| 67 | 
            +
                  instrument :exist, key: key do |payload|
         | 
| 68 | 
            +
                    answer = File.exist? path_for(key)
         | 
| 69 | 
            +
                    payload[:exist] = answer
         | 
| 70 | 
            +
                    answer
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
                end
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                def url(key, expires_in:, filename:, disposition:, content_type:)
         | 
| 75 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 76 | 
            +
                    content_disposition = content_disposition_with(type: disposition, filename: filename)
         | 
| 77 | 
            +
                    verified_key_with_expiration = ActiveStorage.verifier.generate(
         | 
| 78 | 
            +
                      {
         | 
| 79 | 
            +
                        key: key,
         | 
| 80 | 
            +
                        disposition: content_disposition,
         | 
| 81 | 
            +
                        content_type: content_type
         | 
| 82 | 
            +
                      },
         | 
| 83 | 
            +
                      { expires_in: expires_in,
         | 
| 84 | 
            +
                      purpose: :blob_key }
         | 
| 85 | 
            +
                    )
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                    current_uri = URI.parse(current_host)
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                    generated_url = url_helpers.rails_disk_service_url(verified_key_with_expiration,
         | 
| 90 | 
            +
                      protocol: current_uri.scheme,
         | 
| 91 | 
            +
                      host: current_uri.host,
         | 
| 92 | 
            +
                      port: current_uri.port,
         | 
| 93 | 
            +
                      disposition: content_disposition,
         | 
| 94 | 
            +
                      content_type: content_type,
         | 
| 95 | 
            +
                      filename: filename
         | 
| 96 | 
            +
                    )
         | 
| 97 | 
            +
                    payload[:url] = generated_url
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                    generated_url
         | 
| 100 | 
            +
                  end
         | 
| 101 | 
            +
                end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
         | 
| 104 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 105 | 
            +
                    verified_token_with_expiration = ActiveStorage.verifier.generate(
         | 
| 106 | 
            +
                      {
         | 
| 107 | 
            +
                        key: key,
         | 
| 108 | 
            +
                        content_type: content_type,
         | 
| 109 | 
            +
                        content_length: content_length,
         | 
| 110 | 
            +
                        checksum: checksum
         | 
| 111 | 
            +
                      },
         | 
| 112 | 
            +
                      { expires_in: expires_in,
         | 
| 113 | 
            +
                      purpose: :blob_token }
         | 
| 114 | 
            +
                    )
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                    generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                    payload[:url] = generated_url
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                    generated_url
         | 
| 121 | 
            +
                  end
         | 
| 122 | 
            +
                end
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                def headers_for_direct_upload(key, content_type:, **)
         | 
| 125 | 
            +
                  { "Content-Type" => content_type }
         | 
| 126 | 
            +
                end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                def path_for(key) #:nodoc:
         | 
| 129 | 
            +
                  File.join root, folder_for(key), key
         | 
| 130 | 
            +
                end
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                private
         | 
| 133 | 
            +
                  def stream(key)
         | 
| 134 | 
            +
                    File.open(path_for(key), "rb") do |file|
         | 
| 135 | 
            +
                      while data = file.read(5.megabytes)
         | 
| 136 | 
            +
                        yield data
         | 
| 137 | 
            +
                      end
         | 
| 138 | 
            +
                    end
         | 
| 139 | 
            +
                  rescue Errno::ENOENT
         | 
| 140 | 
            +
                    raise ActiveStorage::FileNotFoundError
         | 
| 141 | 
            +
                  end
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                  def folder_for(key)
         | 
| 144 | 
            +
                    [ key[0..1], key[2..3] ].join("/")
         | 
| 145 | 
            +
                  end
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                  def make_path_for(key)
         | 
| 148 | 
            +
                    path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
         | 
| 149 | 
            +
                  end
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                  def ensure_integrity_of(key, checksum)
         | 
| 152 | 
            +
                    unless Digest::MD5.file(path_for(key)).base64digest == checksum
         | 
| 153 | 
            +
                      delete key
         | 
| 154 | 
            +
                      raise ActiveStorage::IntegrityError
         | 
| 155 | 
            +
                    end
         | 
| 156 | 
            +
                  end
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  def url_helpers
         | 
| 159 | 
            +
                    @url_helpers ||= Rails.application.routes.url_helpers
         | 
| 160 | 
            +
                  end
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                  def current_host
         | 
| 163 | 
            +
                    ActiveStorage::Current.host
         | 
| 164 | 
            +
                  end
         | 
| 165 | 
            +
              end
         | 
| 166 | 
            +
            end
         | 
| @@ -0,0 +1,141 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            gem "google-cloud-storage", "~> 1.11"
         | 
| 4 | 
            +
            require "google/cloud/storage"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module ActiveStorage
         | 
| 7 | 
            +
              # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
         | 
| 8 | 
            +
              # documentation that applies to all services.
         | 
| 9 | 
            +
              class Service::GCSService < Service
         | 
| 10 | 
            +
                def initialize(**config)
         | 
| 11 | 
            +
                  @config = config
         | 
| 12 | 
            +
                end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
         | 
| 15 | 
            +
                  instrument :upload, key: key, checksum: checksum do
         | 
| 16 | 
            +
                    # GCS's signed URLs don't include params such as response-content-type response-content_disposition
         | 
| 17 | 
            +
                    # in the signature, which means an attacker can modify them and bypass our effort to force these to
         | 
| 18 | 
            +
                    # binary and attachment when the file's content type requires it. The only way to force them is to
         | 
| 19 | 
            +
                    # store them as object's metadata.
         | 
| 20 | 
            +
                    content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 21 | 
            +
                    bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
         | 
| 22 | 
            +
                  rescue Google::Cloud::InvalidArgumentError
         | 
| 23 | 
            +
                    raise ActiveStorage::IntegrityError
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                def download(key, &block)
         | 
| 28 | 
            +
                  if block_given?
         | 
| 29 | 
            +
                    instrument :streaming_download, key: key do
         | 
| 30 | 
            +
                      stream(key, &block)
         | 
| 31 | 
            +
                    end
         | 
| 32 | 
            +
                  else
         | 
| 33 | 
            +
                    instrument :download, key: key do
         | 
| 34 | 
            +
                      file_for(key).download.string
         | 
| 35 | 
            +
                    rescue Google::Cloud::NotFoundError
         | 
| 36 | 
            +
                      raise ActiveStorage::FileNotFoundError
         | 
| 37 | 
            +
                    end
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                def update_metadata(key, content_type:, disposition: nil, filename: nil)
         | 
| 42 | 
            +
                  instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
         | 
| 43 | 
            +
                    file_for(key).update do |file|
         | 
| 44 | 
            +
                      file.content_type = content_type
         | 
| 45 | 
            +
                      file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 46 | 
            +
                    end
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
                end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                def download_chunk(key, range)
         | 
| 51 | 
            +
                  instrument :download_chunk, key: key, range: range do
         | 
| 52 | 
            +
                    file_for(key).download(range: range).string
         | 
| 53 | 
            +
                  rescue Google::Cloud::NotFoundError
         | 
| 54 | 
            +
                    raise ActiveStorage::FileNotFoundError
         | 
| 55 | 
            +
                  end
         | 
| 56 | 
            +
                end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                def delete(key)
         | 
| 59 | 
            +
                  instrument :delete, key: key do
         | 
| 60 | 
            +
                    file_for(key).delete
         | 
| 61 | 
            +
                  rescue Google::Cloud::NotFoundError
         | 
| 62 | 
            +
                    # Ignore files already deleted
         | 
| 63 | 
            +
                  end
         | 
| 64 | 
            +
                end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                def delete_prefixed(prefix)
         | 
| 67 | 
            +
                  instrument :delete_prefixed, prefix: prefix do
         | 
| 68 | 
            +
                    bucket.files(prefix: prefix).all do |file|
         | 
| 69 | 
            +
                      file.delete
         | 
| 70 | 
            +
                    rescue Google::Cloud::NotFoundError
         | 
| 71 | 
            +
                      # Ignore concurrently-deleted files
         | 
| 72 | 
            +
                    end
         | 
| 73 | 
            +
                  end
         | 
| 74 | 
            +
                end
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                def exist?(key)
         | 
| 77 | 
            +
                  instrument :exist, key: key do |payload|
         | 
| 78 | 
            +
                    answer = file_for(key).exists?
         | 
| 79 | 
            +
                    payload[:exist] = answer
         | 
| 80 | 
            +
                    answer
         | 
| 81 | 
            +
                  end
         | 
| 82 | 
            +
                end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                def url(key, expires_in:, filename:, content_type:, disposition:)
         | 
| 85 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 86 | 
            +
                    generated_url = file_for(key).signed_url expires: expires_in, query: {
         | 
| 87 | 
            +
                      "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
         | 
| 88 | 
            +
                      "response-content-type" => content_type
         | 
| 89 | 
            +
                    }
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                    payload[:url] = generated_url
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                    generated_url
         | 
| 94 | 
            +
                  end
         | 
| 95 | 
            +
                end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                def url_for_direct_upload(key, expires_in:, checksum:, **)
         | 
| 98 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 99 | 
            +
                    generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    payload[:url] = generated_url
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                    generated_url
         | 
| 104 | 
            +
                  end
         | 
| 105 | 
            +
                end
         | 
| 106 | 
            +
             | 
| 107 | 
            +
                def headers_for_direct_upload(key, checksum:, **)
         | 
| 108 | 
            +
                  { "Content-MD5" => checksum }
         | 
| 109 | 
            +
                end
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                private
         | 
| 112 | 
            +
                  attr_reader :config
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                  def file_for(key, skip_lookup: true)
         | 
| 115 | 
            +
                    bucket.file(key, skip_lookup: skip_lookup)
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                  # Reads the file for the given key in chunks, yielding each to the block.
         | 
| 119 | 
            +
                  def stream(key)
         | 
| 120 | 
            +
                    file = file_for(key, skip_lookup: false)
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                    chunk_size = 5.megabytes
         | 
| 123 | 
            +
                    offset = 0
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                    raise ActiveStorage::FileNotFoundError unless file.present?
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                    while offset < file.size
         | 
| 128 | 
            +
                      yield file.download(range: offset..(offset + chunk_size - 1)).string
         | 
| 129 | 
            +
                      offset += chunk_size
         | 
| 130 | 
            +
                    end
         | 
| 131 | 
            +
                  end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                  def bucket
         | 
| 134 | 
            +
                    @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true)
         | 
| 135 | 
            +
                  end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                  def client
         | 
| 138 | 
            +
                    @client ||= Google::Cloud::Storage.new(config.except(:bucket))
         | 
| 139 | 
            +
                  end
         | 
| 140 | 
            +
              end
         | 
| 141 | 
            +
            end
         | 
| @@ -0,0 +1,55 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "active_support/core_ext/module/delegation"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module ActiveStorage
         | 
| 6 | 
            +
              # Wraps a set of mirror services and provides a single ActiveStorage::Service object that will all
         | 
| 7 | 
            +
              # have the files uploaded to them. A +primary+ service is designated to answer calls to +download+, +exists?+,
         | 
| 8 | 
            +
              # and +url+.
         | 
| 9 | 
            +
              class Service::MirrorService < Service
         | 
| 10 | 
            +
                attr_reader :primary, :mirrors
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                delegate :download, :download_chunk, :exist?, :url, :path_for, to: :primary
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                # Stitch together from named services.
         | 
| 15 | 
            +
                def self.build(primary:, mirrors:, configurator:, **options) #:nodoc:
         | 
| 16 | 
            +
                  new \
         | 
| 17 | 
            +
                    primary: configurator.build(primary),
         | 
| 18 | 
            +
                    mirrors: mirrors.collect { |name| configurator.build name }
         | 
| 19 | 
            +
                end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                def initialize(primary:, mirrors:)
         | 
| 22 | 
            +
                  @primary, @mirrors = primary, mirrors
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                # Upload the +io+ to the +key+ specified to all services. If a +checksum+ is provided, all services will
         | 
| 26 | 
            +
                # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
         | 
| 27 | 
            +
                def upload(key, io, checksum: nil, **options)
         | 
| 28 | 
            +
                  each_service.collect do |service|
         | 
| 29 | 
            +
                    service.upload key, io.tap(&:rewind), checksum: checksum, **options
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                # Delete the file at the +key+ on all services.
         | 
| 34 | 
            +
                def delete(key)
         | 
| 35 | 
            +
                  perform_across_services :delete, key
         | 
| 36 | 
            +
                end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                # Delete files at keys starting with the +prefix+ on all services.
         | 
| 39 | 
            +
                def delete_prefixed(prefix)
         | 
| 40 | 
            +
                  perform_across_services :delete_prefixed, prefix
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                private
         | 
| 44 | 
            +
                  def each_service(&block)
         | 
| 45 | 
            +
                    [ primary, *mirrors ].each(&block)
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  def perform_across_services(method, *args)
         | 
| 49 | 
            +
                    # FIXME: Convert to be threaded
         | 
| 50 | 
            +
                    each_service.collect do |service|
         | 
| 51 | 
            +
                      service.public_send method, *args
         | 
| 52 | 
            +
                    end
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
              end
         | 
| 55 | 
            +
            end
         | 
| @@ -0,0 +1,116 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "aws-sdk-s3"
         | 
| 4 | 
            +
            require "active_support/core_ext/numeric/bytes"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module ActiveStorage
         | 
| 7 | 
            +
              # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service.
         | 
| 8 | 
            +
              # See ActiveStorage::Service for the generic API documentation that applies to all services.
         | 
| 9 | 
            +
              class Service::S3Service < Service
         | 
| 10 | 
            +
                attr_reader :client, :bucket, :upload_options
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                def initialize(bucket:, upload: {}, **options)
         | 
| 13 | 
            +
                  @client = Aws::S3::Resource.new(**options)
         | 
| 14 | 
            +
                  @bucket = @client.bucket(bucket)
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  @upload_options = upload
         | 
| 17 | 
            +
                end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                def upload(key, io, checksum: nil, content_type: nil, **)
         | 
| 20 | 
            +
                  instrument :upload, key: key, checksum: checksum do
         | 
| 21 | 
            +
                    object_for(key).put(upload_options.merge(body: io, content_md5: checksum, content_type: content_type))
         | 
| 22 | 
            +
                  rescue Aws::S3::Errors::BadDigest
         | 
| 23 | 
            +
                    raise ActiveStorage::IntegrityError
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                def download(key, &block)
         | 
| 28 | 
            +
                  if block_given?
         | 
| 29 | 
            +
                    instrument :streaming_download, key: key do
         | 
| 30 | 
            +
                      stream(key, &block)
         | 
| 31 | 
            +
                    end
         | 
| 32 | 
            +
                  else
         | 
| 33 | 
            +
                    instrument :download, key: key do
         | 
| 34 | 
            +
                      object_for(key).get.body.string.force_encoding(Encoding::BINARY)
         | 
| 35 | 
            +
                    rescue Aws::S3::Errors::NoSuchKey
         | 
| 36 | 
            +
                      raise ActiveStorage::FileNotFoundError
         | 
| 37 | 
            +
                    end
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                def download_chunk(key, range)
         | 
| 42 | 
            +
                  instrument :download_chunk, key: key, range: range do
         | 
| 43 | 
            +
                    object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.read.force_encoding(Encoding::BINARY)
         | 
| 44 | 
            +
                  rescue Aws::S3::Errors::NoSuchKey
         | 
| 45 | 
            +
                    raise ActiveStorage::FileNotFoundError
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
                end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                def delete(key)
         | 
| 50 | 
            +
                  instrument :delete, key: key do
         | 
| 51 | 
            +
                    object_for(key).delete
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
                end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                def delete_prefixed(prefix)
         | 
| 56 | 
            +
                  instrument :delete_prefixed, prefix: prefix do
         | 
| 57 | 
            +
                    bucket.objects(prefix: prefix).batch_delete!
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
                end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                def exist?(key)
         | 
| 62 | 
            +
                  instrument :exist, key: key do |payload|
         | 
| 63 | 
            +
                    answer = object_for(key).exists?
         | 
| 64 | 
            +
                    payload[:exist] = answer
         | 
| 65 | 
            +
                    answer
         | 
| 66 | 
            +
                  end
         | 
| 67 | 
            +
                end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                def url(key, expires_in:, filename:, disposition:, content_type:)
         | 
| 70 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 71 | 
            +
                    generated_url = object_for(key).presigned_url :get, expires_in: expires_in.to_i,
         | 
| 72 | 
            +
                      response_content_disposition: content_disposition_with(type: disposition, filename: filename),
         | 
| 73 | 
            +
                      response_content_type: content_type
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                    payload[:url] = generated_url
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    generated_url
         | 
| 78 | 
            +
                  end
         | 
| 79 | 
            +
                end
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
         | 
| 82 | 
            +
                  instrument :url, key: key do |payload|
         | 
| 83 | 
            +
                    generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
         | 
| 84 | 
            +
                      content_type: content_type, content_length: content_length, content_md5: checksum
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                    payload[:url] = generated_url
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                    generated_url
         | 
| 89 | 
            +
                  end
         | 
| 90 | 
            +
                end
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                def headers_for_direct_upload(key, content_type:, checksum:, **)
         | 
| 93 | 
            +
                  { "Content-Type" => content_type, "Content-MD5" => checksum }
         | 
| 94 | 
            +
                end
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                private
         | 
| 97 | 
            +
                  def object_for(key)
         | 
| 98 | 
            +
                    bucket.object(key)
         | 
| 99 | 
            +
                  end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                  # Reads the object for the given key in chunks, yielding each to the block.
         | 
| 102 | 
            +
                  def stream(key)
         | 
| 103 | 
            +
                    object = object_for(key)
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                    chunk_size = 5.megabytes
         | 
| 106 | 
            +
                    offset = 0
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                    raise ActiveStorage::FileNotFoundError unless object.exists?
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                    while offset < object.content_length
         | 
| 111 | 
            +
                      yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.read.force_encoding(Encoding::BINARY)
         | 
| 112 | 
            +
                      offset += chunk_size
         | 
| 113 | 
            +
                    end
         | 
| 114 | 
            +
                  end
         | 
| 115 | 
            +
              end
         | 
| 116 | 
            +
            end
         |