activestorage 6.1.5 → 7.0.3
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 +4 -4
- data/CHANGELOG.md +181 -208
- data/README.md +25 -11
- data/app/assets/javascripts/activestorage.esm.js +844 -0
- data/app/assets/javascripts/activestorage.js +257 -376
- data/app/controllers/active_storage/base_controller.rb +0 -9
- data/app/controllers/active_storage/blobs/proxy_controller.rb +15 -4
- data/app/controllers/active_storage/blobs/redirect_controller.rb +6 -4
- data/app/controllers/active_storage/disk_controller.rb +1 -0
- data/app/controllers/active_storage/representations/base_controller.rb +5 -1
- data/app/controllers/active_storage/representations/proxy_controller.rb +7 -3
- data/app/controllers/active_storage/representations/redirect_controller.rb +6 -4
- data/app/controllers/concerns/active_storage/set_blob.rb +6 -2
- data/app/controllers/concerns/active_storage/set_current.rb +3 -3
- data/app/controllers/concerns/active_storage/streaming.rb +65 -0
- data/app/javascript/activestorage/ujs.js +1 -1
- data/app/models/active_storage/attachment.rb +35 -2
- data/app/models/active_storage/blob/representable.rb +7 -5
- data/app/models/active_storage/blob.rb +92 -36
- data/app/models/active_storage/current.rb +12 -2
- data/app/models/active_storage/preview.rb +6 -4
- data/app/models/active_storage/record.rb +1 -1
- data/app/models/active_storage/variant.rb +3 -6
- data/app/models/active_storage/variant_record.rb +2 -0
- data/app/models/active_storage/variant_with_record.rb +9 -5
- data/app/models/active_storage/variation.rb +2 -2
- data/config/routes.rb +10 -10
- data/db/migrate/20170806125915_create_active_storage_tables.rb +32 -11
- data/db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb +4 -0
- data/db/update_migrate/20191206030411_create_active_storage_variant_records.rb +17 -2
- data/db/update_migrate/20211119233751_remove_not_null_on_active_storage_blobs_checksum.rb +7 -0
- data/lib/active_storage/analyzer/audio_analyzer.rb +65 -0
- data/lib/active_storage/analyzer/image_analyzer/image_magick.rb +39 -0
- data/lib/active_storage/analyzer/image_analyzer/vips.rb +49 -0
- data/lib/active_storage/analyzer/image_analyzer.rb +2 -30
- data/lib/active_storage/analyzer/video_analyzer.rb +27 -12
- data/lib/active_storage/analyzer.rb +8 -4
- data/lib/active_storage/attached/changes/create_many.rb +7 -3
- data/lib/active_storage/attached/changes/create_one.rb +1 -1
- data/lib/active_storage/attached/changes/create_one_of_many.rb +1 -1
- data/lib/active_storage/attached/changes/delete_many.rb +1 -1
- data/lib/active_storage/attached/changes/delete_one.rb +1 -1
- data/lib/active_storage/attached/changes/detach_many.rb +18 -0
- data/lib/active_storage/attached/changes/detach_one.rb +24 -0
- data/lib/active_storage/attached/changes/purge_many.rb +27 -0
- data/lib/active_storage/attached/changes/purge_one.rb +27 -0
- data/lib/active_storage/attached/changes.rb +7 -1
- data/lib/active_storage/attached/many.rb +27 -15
- data/lib/active_storage/attached/model.rb +35 -7
- data/lib/active_storage/attached/one.rb +32 -27
- data/lib/active_storage/downloader.rb +4 -4
- data/lib/active_storage/engine.rb +59 -17
- data/lib/active_storage/fixture_set.rb +76 -0
- data/lib/active_storage/gem_version.rb +4 -4
- data/lib/active_storage/previewer.rb +4 -4
- data/lib/active_storage/reflection.rb +12 -2
- data/lib/active_storage/service/azure_storage_service.rb +28 -6
- data/lib/active_storage/service/configurator.rb +1 -1
- data/lib/active_storage/service/disk_service.rb +24 -19
- data/lib/active_storage/service/gcs_service.rb +109 -11
- data/lib/active_storage/service/mirror_service.rb +2 -2
- data/lib/active_storage/service/registry.rb +1 -1
- data/lib/active_storage/service/s3_service.rb +37 -15
- data/lib/active_storage/service.rb +13 -5
- data/lib/active_storage/transformers/image_processing_transformer.rb +7 -294
- data/lib/active_storage/transformers/transformer.rb +1 -1
- data/lib/active_storage/version.rb +1 -1
- data/lib/active_storage.rb +293 -1
- metadata +28 -18
- data/app/controllers/concerns/active_storage/set_headers.rb +0 -12
| @@ -0,0 +1,76 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "active_support/testing/file_fixtures"
         | 
| 4 | 
            +
            require "active_record/secure_token"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module ActiveStorage
         | 
| 7 | 
            +
              # Fixtures are a way of organizing data that you want to test against; in
         | 
| 8 | 
            +
              # short, sample data.
         | 
| 9 | 
            +
              #
         | 
| 10 | 
            +
              # To learn more about fixtures, read the
         | 
| 11 | 
            +
              # {ActiveRecord::FixtureSet}[rdoc-ref:ActiveRecord::FixtureSet] documentation.
         | 
| 12 | 
            +
              #
         | 
| 13 | 
            +
              # === YAML
         | 
| 14 | 
            +
              #
         | 
| 15 | 
            +
              # Like other Active Record-backed models,
         | 
| 16 | 
            +
              # {ActiveStorage::Attachment}[rdoc-ref:ActiveStorage::Attachment] and
         | 
| 17 | 
            +
              # {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob] records inherit from
         | 
| 18 | 
            +
              # {ActiveRecord::Base}[rdoc-ref:ActiveRecord::Base] instances and therefore
         | 
| 19 | 
            +
              # can be populated by fixtures.
         | 
| 20 | 
            +
              #
         | 
| 21 | 
            +
              # Consider a hypothetical <tt>Article</tt> model class, its related
         | 
| 22 | 
            +
              # fixture data, as well as fixture data for related ActiveStorage::Attachment
         | 
| 23 | 
            +
              # and ActiveStorage::Blob records:
         | 
| 24 | 
            +
              #
         | 
| 25 | 
            +
              #   # app/models/article.rb
         | 
| 26 | 
            +
              #   class Article < ApplicationRecord
         | 
| 27 | 
            +
              #     has_one_attached :thumbnail
         | 
| 28 | 
            +
              #   end
         | 
| 29 | 
            +
              #
         | 
| 30 | 
            +
              #   # fixtures/active_storage/blobs.yml
         | 
| 31 | 
            +
              #   first_thumbnail_blob: <%= ActiveStorage::FixtureSet.blob filename: "first.png" %>
         | 
| 32 | 
            +
              #
         | 
| 33 | 
            +
              #   # fixtures/active_storage/attachments.yml
         | 
| 34 | 
            +
              #   first_thumbnail_attachment:
         | 
| 35 | 
            +
              #     name: thumbnail
         | 
| 36 | 
            +
              #     record: first (Article)
         | 
| 37 | 
            +
              #     blob: first_thumbnail_blob
         | 
| 38 | 
            +
              #
         | 
| 39 | 
            +
              # When processed, Active Record will insert database records for each fixture
         | 
| 40 | 
            +
              # entry and will ensure the Active Storage relationship is intact.
         | 
| 41 | 
            +
              class FixtureSet
         | 
| 42 | 
            +
                include ActiveSupport::Testing::FileFixtures
         | 
| 43 | 
            +
                include ActiveRecord::SecureToken
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                # Generate a YAML-encoded representation of an ActiveStorage::Blob
         | 
| 46 | 
            +
                # instance's attributes, resolve the file relative to the directory mentioned
         | 
| 47 | 
            +
                # by <tt>ActiveSupport::Testing::FileFixtures.file_fixture</tt>, and upload
         | 
| 48 | 
            +
                # the file to the Service
         | 
| 49 | 
            +
                #
         | 
| 50 | 
            +
                # === Examples
         | 
| 51 | 
            +
                #
         | 
| 52 | 
            +
                #   # tests/fixtures/action_text/blobs.yml
         | 
| 53 | 
            +
                #   second_thumbnail_blob: <%= ActiveStorage::FixtureSet.blob(
         | 
| 54 | 
            +
                #     filename: "second.svg",
         | 
| 55 | 
            +
                #   ) %>
         | 
| 56 | 
            +
                #
         | 
| 57 | 
            +
                #   third_thumbnail_blob: <%= ActiveStorage::FixtureSet.blob(
         | 
| 58 | 
            +
                #     filename: "third.svg",
         | 
| 59 | 
            +
                #     content_type: "image/svg+xml",
         | 
| 60 | 
            +
                #     service_name: "public"
         | 
| 61 | 
            +
                #   ) %>
         | 
| 62 | 
            +
                #
         | 
| 63 | 
            +
                def self.blob(filename:, **attributes)
         | 
| 64 | 
            +
                  new.prepare Blob.new(filename: filename, key: generate_unique_secure_token), **attributes
         | 
| 65 | 
            +
                end
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                def prepare(instance, **attributes)
         | 
| 68 | 
            +
                  io = file_fixture(instance.filename.to_s).open
         | 
| 69 | 
            +
                  instance.unfurl(io)
         | 
| 70 | 
            +
                  instance.assign_attributes(attributes)
         | 
| 71 | 
            +
                  instance.upload_without_unfurling(io)
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  instance.attributes.transform_values { |value| value.is_a?(Hash) ? value.to_json : value }.compact.to_json
         | 
| 74 | 
            +
                end
         | 
| 75 | 
            +
              end
         | 
| 76 | 
            +
            end
         | 
| @@ -1,15 +1,15 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 3 | 
             
            module ActiveStorage
         | 
| 4 | 
            -
              # Returns the version of  | 
| 4 | 
            +
              # Returns the currently loaded version of Active Storage as a <tt>Gem::Version</tt>.
         | 
| 5 5 | 
             
              def self.gem_version
         | 
| 6 6 | 
             
                Gem::Version.new VERSION::STRING
         | 
| 7 7 | 
             
              end
         | 
| 8 8 |  | 
| 9 9 | 
             
              module VERSION
         | 
| 10 | 
            -
                MAJOR =  | 
| 11 | 
            -
                MINOR =  | 
| 12 | 
            -
                TINY  =  | 
| 10 | 
            +
                MAJOR = 7
         | 
| 11 | 
            +
                MINOR = 0
         | 
| 12 | 
            +
                TINY  = 3
         | 
| 13 13 | 
             
                PRE   = nil
         | 
| 14 14 |  | 
| 15 15 | 
             
                STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
         | 
| @@ -26,7 +26,7 @@ module ActiveStorage | |
| 26 26 |  | 
| 27 27 | 
             
                private
         | 
| 28 28 | 
             
                  # Downloads the blob to a tempfile on disk. Yields the tempfile.
         | 
| 29 | 
            -
                  def download_blob_to_tempfile(&block)  | 
| 29 | 
            +
                  def download_blob_to_tempfile(&block) # :doc:
         | 
| 30 30 | 
             
                    blob.open tmpdir: tmpdir, &block
         | 
| 31 31 | 
             
                  end
         | 
| 32 32 |  | 
| @@ -44,7 +44,7 @@ module ActiveStorage | |
| 44 44 | 
             
                  #   end
         | 
| 45 45 | 
             
                  #
         | 
| 46 46 | 
             
                  # The output tempfile is opened in the directory returned by #tmpdir.
         | 
| 47 | 
            -
                  def draw(*argv)  | 
| 47 | 
            +
                  def draw(*argv) # :doc:
         | 
| 48 48 | 
             
                    open_tempfile do |file|
         | 
| 49 49 | 
             
                      instrument :preview, key: blob.key do
         | 
| 50 50 | 
             
                        capture(*argv, to: file)
         | 
| @@ -83,11 +83,11 @@ module ActiveStorage | |
| 83 83 | 
             
                    to.rewind
         | 
| 84 84 | 
             
                  end
         | 
| 85 85 |  | 
| 86 | 
            -
                  def logger  | 
| 86 | 
            +
                  def logger # :doc:
         | 
| 87 87 | 
             
                    ActiveStorage.logger
         | 
| 88 88 | 
             
                  end
         | 
| 89 89 |  | 
| 90 | 
            -
                  def tmpdir  | 
| 90 | 
            +
                  def tmpdir # :doc:
         | 
| 91 91 | 
             
                    Dir.tmpdir
         | 
| 92 92 | 
             
                  end
         | 
| 93 93 | 
             
              end
         | 
| @@ -2,9 +2,19 @@ | |
| 2 2 |  | 
| 3 3 | 
             
            module ActiveStorage
         | 
| 4 4 | 
             
              module Reflection
         | 
| 5 | 
            +
                class HasAttachedReflection < ActiveRecord::Reflection::MacroReflection # :nodoc:
         | 
| 6 | 
            +
                  def variant(name, transformations)
         | 
| 7 | 
            +
                    variants[name] = transformations
         | 
| 8 | 
            +
                  end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def variants
         | 
| 11 | 
            +
                    @variants ||= {}
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
             | 
| 5 15 | 
             
                # Holds all the metadata about a has_one_attached attachment as it was
         | 
| 6 16 | 
             
                # specified in the Active Record class.
         | 
| 7 | 
            -
                class HasOneAttachedReflection <  | 
| 17 | 
            +
                class HasOneAttachedReflection < HasAttachedReflection # :nodoc:
         | 
| 8 18 | 
             
                  def macro
         | 
| 9 19 | 
             
                    :has_one_attached
         | 
| 10 20 | 
             
                  end
         | 
| @@ -12,7 +22,7 @@ module ActiveStorage | |
| 12 22 |  | 
| 13 23 | 
             
                # Holds all the metadata about a has_many_attached attachment as it was
         | 
| 14 24 | 
             
                # specified in the Active Record class.
         | 
| 15 | 
            -
                class HasManyAttachedReflection <  | 
| 25 | 
            +
                class HasManyAttachedReflection < HasAttachedReflection # :nodoc:
         | 
| 16 26 | 
             
                  def macro
         | 
| 17 27 | 
             
                    :has_many_attached
         | 
| 18 28 | 
             
                  end
         | 
| @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            gem "azure-storage-blob", ">=  | 
| 3 | 
            +
            gem "azure-storage-blob", ">= 2.0"
         | 
| 4 4 |  | 
| 5 5 | 
             
            require "active_support/core_ext/numeric/bytes"
         | 
| 6 6 | 
             
            require "azure/storage/blob"
         | 
| @@ -19,12 +19,12 @@ module ActiveStorage | |
| 19 19 | 
             
                  @public = public
         | 
| 20 20 | 
             
                end
         | 
| 21 21 |  | 
| 22 | 
            -
                def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
         | 
| 22 | 
            +
                def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **)
         | 
| 23 23 | 
             
                  instrument :upload, key: key, checksum: checksum do
         | 
| 24 24 | 
             
                    handle_errors do
         | 
| 25 25 | 
             
                      content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
         | 
| 26 26 |  | 
| 27 | 
            -
                      client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition)
         | 
| 27 | 
            +
                      client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata)
         | 
| 28 28 | 
             
                    end
         | 
| 29 29 | 
             
                  end
         | 
| 30 30 | 
             
                end
         | 
| @@ -86,7 +86,7 @@ module ActiveStorage | |
| 86 86 | 
             
                  end
         | 
| 87 87 | 
             
                end
         | 
| 88 88 |  | 
| 89 | 
            -
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
         | 
| 89 | 
            +
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
         | 
| 90 90 | 
             
                  instrument :url, key: key do |payload|
         | 
| 91 91 | 
             
                    generated_url = signer.signed_uri(
         | 
| 92 92 | 
             
                      uri_for(key), false,
         | 
| @@ -101,10 +101,28 @@ module ActiveStorage | |
| 101 101 | 
             
                  end
         | 
| 102 102 | 
             
                end
         | 
| 103 103 |  | 
| 104 | 
            -
                def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
         | 
| 104 | 
            +
                def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
         | 
| 105 105 | 
             
                  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
         | 
| 106 106 |  | 
| 107 | 
            -
                  { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob" }
         | 
| 107 | 
            +
                  { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) }
         | 
| 108 | 
            +
                end
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
         | 
| 111 | 
            +
                  content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                  client.create_append_blob(
         | 
| 114 | 
            +
                    container,
         | 
| 115 | 
            +
                    destination_key,
         | 
| 116 | 
            +
                    content_type: content_type,
         | 
| 117 | 
            +
                    content_disposition: content_disposition,
         | 
| 118 | 
            +
                    metadata: custom_metadata,
         | 
| 119 | 
            +
                  ).tap do |blob|
         | 
| 120 | 
            +
                    source_keys.each do |source_key|
         | 
| 121 | 
            +
                      stream(source_key) do |chunk|
         | 
| 122 | 
            +
                        client.append_blob_block(container, blob.name, chunk)
         | 
| 123 | 
            +
                      end
         | 
| 124 | 
            +
                    end
         | 
| 125 | 
            +
                  end
         | 
| 108 126 | 
             
                end
         | 
| 109 127 |  | 
| 110 128 | 
             
                private
         | 
| @@ -166,5 +184,9 @@ module ActiveStorage | |
| 166 184 | 
             
                      raise
         | 
| 167 185 | 
             
                    end
         | 
| 168 186 | 
             
                  end
         | 
| 187 | 
            +
             | 
| 188 | 
            +
                  def custom_metadata_headers(metadata)
         | 
| 189 | 
            +
                    metadata.transform_keys { |key| "x-ms-meta-#{key}" }
         | 
| 190 | 
            +
                  end
         | 
| 169 191 | 
             
              end
         | 
| 170 192 | 
             
            end
         | 
| @@ -2,14 +2,14 @@ | |
| 2 2 |  | 
| 3 3 | 
             
            require "fileutils"
         | 
| 4 4 | 
             
            require "pathname"
         | 
| 5 | 
            -
            require " | 
| 5 | 
            +
            require "openssl"
         | 
| 6 6 | 
             
            require "active_support/core_ext/numeric/bytes"
         | 
| 7 7 |  | 
| 8 8 | 
             
            module ActiveStorage
         | 
| 9 9 | 
             
              # Wraps a local disk path as an Active Storage service. See ActiveStorage::Service for the generic API
         | 
| 10 10 | 
             
              # documentation that applies to all services.
         | 
| 11 11 | 
             
              class Service::DiskService < Service
         | 
| 12 | 
            -
                 | 
| 12 | 
            +
                attr_accessor :root
         | 
| 13 13 |  | 
| 14 14 | 
             
                def initialize(root:, public: false, **options)
         | 
| 15 15 | 
             
                  @root = root
         | 
| @@ -72,7 +72,7 @@ module ActiveStorage | |
| 72 72 | 
             
                  end
         | 
| 73 73 | 
             
                end
         | 
| 74 74 |  | 
| 75 | 
            -
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
         | 
| 75 | 
            +
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
         | 
| 76 76 | 
             
                  instrument :url, key: key do |payload|
         | 
| 77 77 | 
             
                    verified_token_with_expiration = ActiveStorage.verifier.generate(
         | 
| 78 78 | 
             
                      {
         | 
| @@ -86,11 +86,9 @@ module ActiveStorage | |
| 86 86 | 
             
                      purpose: :blob_token
         | 
| 87 87 | 
             
                    )
         | 
| 88 88 |  | 
| 89 | 
            -
                     | 
| 90 | 
            -
             | 
| 91 | 
            -
                     | 
| 92 | 
            -
             | 
| 93 | 
            -
                    generated_url
         | 
| 89 | 
            +
                    url_helpers.update_rails_disk_service_url(verified_token_with_expiration, url_options).tap do |generated_url|
         | 
| 90 | 
            +
                      payload[:url] = generated_url
         | 
| 91 | 
            +
                    end
         | 
| 94 92 | 
             
                  end
         | 
| 95 93 | 
             
                end
         | 
| 96 94 |  | 
| @@ -98,10 +96,20 @@ module ActiveStorage | |
| 98 96 | 
             
                  { "Content-Type" => content_type }
         | 
| 99 97 | 
             
                end
         | 
| 100 98 |  | 
| 101 | 
            -
                def path_for(key)  | 
| 99 | 
            +
                def path_for(key) # :nodoc:
         | 
| 102 100 | 
             
                  File.join root, folder_for(key), key
         | 
| 103 101 | 
             
                end
         | 
| 104 102 |  | 
| 103 | 
            +
                def compose(source_keys, destination_key, **)
         | 
| 104 | 
            +
                  File.open(make_path_for(destination_key), "w") do |destination_file|
         | 
| 105 | 
            +
                    source_keys.each do |source_key|
         | 
| 106 | 
            +
                      File.open(path_for(source_key), "rb") do |source_file|
         | 
| 107 | 
            +
                        IO.copy_stream(source_file, destination_file)
         | 
| 108 | 
            +
                      end
         | 
| 109 | 
            +
                    end
         | 
| 110 | 
            +
                  end
         | 
| 111 | 
            +
                end
         | 
| 112 | 
            +
             | 
| 105 113 | 
             
                private
         | 
| 106 114 | 
             
                  def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
         | 
| 107 115 | 
             
                    generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
         | 
| @@ -124,14 +132,11 @@ module ActiveStorage | |
| 124 132 | 
             
                      purpose: :blob_key
         | 
| 125 133 | 
             
                    )
         | 
| 126 134 |  | 
| 127 | 
            -
                     | 
| 135 | 
            +
                    if url_options.blank?
         | 
| 136 | 
            +
                      raise ArgumentError, "Cannot generate URL for #{filename} using Disk service, please set ActiveStorage::Current.url_options."
         | 
| 137 | 
            +
                    end
         | 
| 128 138 |  | 
| 129 | 
            -
                    url_helpers.rails_disk_service_url(verified_key_with_expiration,
         | 
| 130 | 
            -
                      protocol: current_uri.scheme,
         | 
| 131 | 
            -
                      host: current_uri.host,
         | 
| 132 | 
            -
                      port: current_uri.port,
         | 
| 133 | 
            -
                      filename: filename
         | 
| 134 | 
            -
                    )
         | 
| 139 | 
            +
                    url_helpers.rails_disk_service_url(verified_key_with_expiration, filename: filename, **url_options)
         | 
| 135 140 | 
             
                  end
         | 
| 136 141 |  | 
| 137 142 |  | 
| @@ -154,7 +159,7 @@ module ActiveStorage | |
| 154 159 | 
             
                  end
         | 
| 155 160 |  | 
| 156 161 | 
             
                  def ensure_integrity_of(key, checksum)
         | 
| 157 | 
            -
                    unless Digest::MD5.file(path_for(key)).base64digest == checksum
         | 
| 162 | 
            +
                    unless OpenSSL::Digest::MD5.file(path_for(key)).base64digest == checksum
         | 
| 158 163 | 
             
                      delete key
         | 
| 159 164 | 
             
                      raise ActiveStorage::IntegrityError
         | 
| 160 165 | 
             
                    end
         | 
| @@ -164,8 +169,8 @@ module ActiveStorage | |
| 164 169 | 
             
                    @url_helpers ||= Rails.application.routes.url_helpers
         | 
| 165 170 | 
             
                  end
         | 
| 166 171 |  | 
| 167 | 
            -
                  def  | 
| 168 | 
            -
                    ActiveStorage::Current. | 
| 172 | 
            +
                  def url_options
         | 
| 173 | 
            +
                    ActiveStorage::Current.url_options
         | 
| 169 174 | 
             
                  end
         | 
| 170 175 | 
             
              end
         | 
| 171 176 | 
             
            end
         | 
| @@ -1,25 +1,29 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 3 | 
             
            gem "google-cloud-storage", "~> 1.11"
         | 
| 4 | 
            +
            require "google/apis/iamcredentials_v1"
         | 
| 4 5 | 
             
            require "google/cloud/storage"
         | 
| 5 6 |  | 
| 6 7 | 
             
            module ActiveStorage
         | 
| 7 8 | 
             
              # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
         | 
| 8 9 | 
             
              # documentation that applies to all services.
         | 
| 9 10 | 
             
              class Service::GCSService < Service
         | 
| 11 | 
            +
                class MetadataServerError < ActiveStorage::Error; end
         | 
| 12 | 
            +
                class MetadataServerNotFoundError < ActiveStorage::Error; end
         | 
| 13 | 
            +
             | 
| 10 14 | 
             
                def initialize(public: false, **config)
         | 
| 11 15 | 
             
                  @config = config
         | 
| 12 16 | 
             
                  @public = public
         | 
| 13 17 | 
             
                end
         | 
| 14 18 |  | 
| 15 | 
            -
                def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
         | 
| 19 | 
            +
                def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {})
         | 
| 16 20 | 
             
                  instrument :upload, key: key, checksum: checksum do
         | 
| 17 21 | 
             
                    # GCS's signed URLs don't include params such as response-content-type response-content_disposition
         | 
| 18 22 | 
             
                    # in the signature, which means an attacker can modify them and bypass our effort to force these to
         | 
| 19 23 | 
             
                    # binary and attachment when the file's content type requires it. The only way to force them is to
         | 
| 20 24 | 
             
                    # store them as object's metadata.
         | 
| 21 25 | 
             
                    content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 22 | 
            -
                    bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
         | 
| 26 | 
            +
                    bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata)
         | 
| 23 27 | 
             
                  rescue Google::Cloud::InvalidArgumentError
         | 
| 24 28 | 
             
                    raise ActiveStorage::IntegrityError
         | 
| 25 29 | 
             
                  end
         | 
| @@ -39,11 +43,12 @@ module ActiveStorage | |
| 39 43 | 
             
                  end
         | 
| 40 44 | 
             
                end
         | 
| 41 45 |  | 
| 42 | 
            -
                def update_metadata(key, content_type:, disposition: nil, filename: nil)
         | 
| 46 | 
            +
                def update_metadata(key, content_type:, disposition: nil, filename: nil, custom_metadata: {})
         | 
| 43 47 | 
             
                  instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
         | 
| 44 48 | 
             
                    file_for(key).update do |file|
         | 
| 45 49 | 
             
                      file.content_type = content_type
         | 
| 46 50 | 
             
                      file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 51 | 
            +
                      file.metadata = custom_metadata
         | 
| 47 52 | 
             
                    end
         | 
| 48 53 | 
             
                  end
         | 
| 49 54 | 
             
                end
         | 
| @@ -82,9 +87,35 @@ module ActiveStorage | |
| 82 87 | 
             
                  end
         | 
| 83 88 | 
             
                end
         | 
| 84 89 |  | 
| 85 | 
            -
                def url_for_direct_upload(key, expires_in:, checksum:, **)
         | 
| 90 | 
            +
                def url_for_direct_upload(key, expires_in:, checksum:, custom_metadata: {}, **)
         | 
| 86 91 | 
             
                  instrument :url, key: key do |payload|
         | 
| 87 | 
            -
                     | 
| 92 | 
            +
                    headers = {}
         | 
| 93 | 
            +
                    version = :v2
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                    if @config[:cache_control].present?
         | 
| 96 | 
            +
                      headers["Cache-Control"] = @config[:cache_control]
         | 
| 97 | 
            +
                      # v2 signing doesn't support non `x-goog-` headers. Only switch to v4 signing
         | 
| 98 | 
            +
                      # if necessary for back-compat; v4 limits the expiration of the URL to 7 days
         | 
| 99 | 
            +
                      # whereas v2 has no limit
         | 
| 100 | 
            +
                      version = :v4
         | 
| 101 | 
            +
                    end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                    headers.merge!(custom_metadata_headers(custom_metadata))
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                    args = {
         | 
| 106 | 
            +
                      content_md5: checksum,
         | 
| 107 | 
            +
                      expires: expires_in,
         | 
| 108 | 
            +
                      headers: headers,
         | 
| 109 | 
            +
                      method: "PUT",
         | 
| 110 | 
            +
                      version: version,
         | 
| 111 | 
            +
                    }
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                    if @config[:iam]
         | 
| 114 | 
            +
                      args[:issuer] = issuer
         | 
| 115 | 
            +
                      args[:signer] = signer
         | 
| 116 | 
            +
                    end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                    generated_url = bucket.signed_url(key, **args)
         | 
| 88 119 |  | 
| 89 120 | 
             
                    payload[:url] = generated_url
         | 
| 90 121 |  | 
| @@ -92,18 +123,41 @@ module ActiveStorage | |
| 92 123 | 
             
                  end
         | 
| 93 124 | 
             
                end
         | 
| 94 125 |  | 
| 95 | 
            -
                def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, **)
         | 
| 126 | 
            +
                def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
         | 
| 96 127 | 
             
                  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
         | 
| 97 128 |  | 
| 98 | 
            -
                  { "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
         | 
| 129 | 
            +
                  headers = { "Content-MD5" => checksum, "Content-Disposition" => content_disposition, **custom_metadata_headers(custom_metadata) }
         | 
| 130 | 
            +
                  if @config[:cache_control].present?
         | 
| 131 | 
            +
                    headers["Cache-Control"] = @config[:cache_control]
         | 
| 132 | 
            +
                  end
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                  headers
         | 
| 135 | 
            +
                end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
         | 
| 138 | 
            +
                  bucket.compose(source_keys, destination_key).update do |file|
         | 
| 139 | 
            +
                    file.content_type = content_type
         | 
| 140 | 
            +
                    file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 141 | 
            +
                    file.metadata = custom_metadata
         | 
| 142 | 
            +
                  end
         | 
| 99 143 | 
             
                end
         | 
| 100 144 |  | 
| 101 145 | 
             
                private
         | 
| 102 146 | 
             
                  def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
         | 
| 103 | 
            -
                     | 
| 104 | 
            -
                       | 
| 105 | 
            -
                       | 
| 147 | 
            +
                    args = {
         | 
| 148 | 
            +
                      expires: expires_in,
         | 
| 149 | 
            +
                      query: {
         | 
| 150 | 
            +
                        "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
         | 
| 151 | 
            +
                        "response-content-type" => content_type
         | 
| 152 | 
            +
                      }
         | 
| 106 153 | 
             
                    }
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                    if @config[:iam]
         | 
| 156 | 
            +
                      args[:issuer] = issuer
         | 
| 157 | 
            +
                      args[:signer] = signer
         | 
| 158 | 
            +
                    end
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                    file_for(key).signed_url(**args)
         | 
| 107 161 | 
             
                  end
         | 
| 108 162 |  | 
| 109 163 | 
             
                  def public_url(key, **)
         | 
| @@ -137,7 +191,51 @@ module ActiveStorage | |
| 137 191 | 
             
                  end
         | 
| 138 192 |  | 
| 139 193 | 
             
                  def client
         | 
| 140 | 
            -
                    @client ||= Google::Cloud::Storage.new(**config.except(:bucket))
         | 
| 194 | 
            +
                    @client ||= Google::Cloud::Storage.new(**config.except(:bucket, :cache_control, :iam, :gsa_email))
         | 
| 195 | 
            +
                  end
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                  def issuer
         | 
| 198 | 
            +
                    @issuer ||= if @config[:gsa_email]
         | 
| 199 | 
            +
                      @config[:gsa_email]
         | 
| 200 | 
            +
                    else
         | 
| 201 | 
            +
                      uri = URI.parse("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email")
         | 
| 202 | 
            +
                      http = Net::HTTP.new(uri.host, uri.port)
         | 
| 203 | 
            +
                      request = Net::HTTP::Get.new(uri.request_uri)
         | 
| 204 | 
            +
                      request["Metadata-Flavor"] = "Google"
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                      begin
         | 
| 207 | 
            +
                        response = http.request(request)
         | 
| 208 | 
            +
                      rescue SocketError
         | 
| 209 | 
            +
                        raise MetadataServerNotFoundError
         | 
| 210 | 
            +
                      end
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                      if response.is_a?(Net::HTTPSuccess)
         | 
| 213 | 
            +
                        response.body
         | 
| 214 | 
            +
                      else
         | 
| 215 | 
            +
                        raise MetadataServerError
         | 
| 216 | 
            +
                      end
         | 
| 217 | 
            +
                    end
         | 
| 218 | 
            +
                  end
         | 
| 219 | 
            +
             | 
| 220 | 
            +
                  def signer
         | 
| 221 | 
            +
                    # https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/Project.html#signed_url-instance_method
         | 
| 222 | 
            +
                    lambda do |string_to_sign|
         | 
| 223 | 
            +
                      iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                      scopes = ["https://www.googleapis.com/auth/iam"]
         | 
| 226 | 
            +
                      iam_client.authorization = Google::Auth.get_application_default(scopes)
         | 
| 227 | 
            +
             | 
| 228 | 
            +
                      request = Google::Apis::IamcredentialsV1::SignBlobRequest.new(
         | 
| 229 | 
            +
                        payload: string_to_sign
         | 
| 230 | 
            +
                      )
         | 
| 231 | 
            +
                      resource = "projects/-/serviceAccounts/#{issuer}"
         | 
| 232 | 
            +
                      response = iam_client.sign_service_account_blob(resource, request)
         | 
| 233 | 
            +
                      response.signed_blob
         | 
| 234 | 
            +
                    end
         | 
| 235 | 
            +
                  end
         | 
| 236 | 
            +
             | 
| 237 | 
            +
                  def custom_metadata_headers(metadata)
         | 
| 238 | 
            +
                    metadata.transform_keys { |key| "x-goog-meta-#{key}" }
         | 
| 141 239 | 
             
                  end
         | 
| 142 240 | 
             
              end
         | 
| 143 241 | 
             
            end
         | 
| @@ -14,10 +14,10 @@ module ActiveStorage | |
| 14 14 | 
             
                attr_reader :primary, :mirrors
         | 
| 15 15 |  | 
| 16 16 | 
             
                delegate :download, :download_chunk, :exist?, :url,
         | 
| 17 | 
            -
                  :url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
         | 
| 17 | 
            +
                  :url_for_direct_upload, :headers_for_direct_upload, :path_for, :compose, to: :primary
         | 
| 18 18 |  | 
| 19 19 | 
             
                # Stitch together from named services.
         | 
| 20 | 
            -
                def self.build(primary:, mirrors:, name:, configurator:, **options)  | 
| 20 | 
            +
                def self.build(primary:, mirrors:, name:, configurator:, **options) # :nodoc:
         | 
| 21 21 | 
             
                  new(
         | 
| 22 22 | 
             
                    primary: configurator.build(primary),
         | 
| 23 23 | 
             
                    mirrors: mirrors.collect { |mirror_name| configurator.build mirror_name }
         | 
| @@ -23,14 +23,14 @@ module ActiveStorage | |
| 23 23 | 
             
                  @upload_options[:acl] = "public-read" if public?
         | 
| 24 24 | 
             
                end
         | 
| 25 25 |  | 
| 26 | 
            -
                def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
         | 
| 26 | 
            +
                def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **)
         | 
| 27 27 | 
             
                  instrument :upload, key: key, checksum: checksum do
         | 
| 28 28 | 
             
                    content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
         | 
| 29 29 |  | 
| 30 30 | 
             
                    if io.size < multipart_upload_threshold
         | 
| 31 | 
            -
                      upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
         | 
| 31 | 
            +
                      upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition, custom_metadata: custom_metadata
         | 
| 32 32 | 
             
                    else
         | 
| 33 | 
            -
                      upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
         | 
| 33 | 
            +
                      upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition, custom_metadata: custom_metadata
         | 
| 34 34 | 
             
                    end
         | 
| 35 35 | 
             
                  end
         | 
| 36 36 | 
             
                end
         | 
| @@ -77,11 +77,11 @@ module ActiveStorage | |
| 77 77 | 
             
                  end
         | 
| 78 78 | 
             
                end
         | 
| 79 79 |  | 
| 80 | 
            -
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
         | 
| 80 | 
            +
                def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
         | 
| 81 81 | 
             
                  instrument :url, key: key do |payload|
         | 
| 82 82 | 
             
                    generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
         | 
| 83 83 | 
             
                      content_type: content_type, content_length: content_length, content_md5: checksum,
         | 
| 84 | 
            -
                      whitelist_headers: ["content-length"], **upload_options
         | 
| 84 | 
            +
                      metadata: custom_metadata, whitelist_headers: ["content-length"], **upload_options
         | 
| 85 85 |  | 
| 86 86 | 
             
                    payload[:url] = generated_url
         | 
| 87 87 |  | 
| @@ -89,37 +89,55 @@ module ActiveStorage | |
| 89 89 | 
             
                  end
         | 
| 90 90 | 
             
                end
         | 
| 91 91 |  | 
| 92 | 
            -
                def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
         | 
| 92 | 
            +
                def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
         | 
| 93 93 | 
             
                  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
         | 
| 94 94 |  | 
| 95 | 
            -
                  { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
         | 
| 95 | 
            +
                  { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition, **custom_metadata_headers(custom_metadata) }
         | 
| 96 | 
            +
                end
         | 
| 97 | 
            +
             | 
| 98 | 
            +
                def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
         | 
| 99 | 
            +
                  content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                  object_for(destination_key).upload_stream(
         | 
| 102 | 
            +
                    content_type: content_type,
         | 
| 103 | 
            +
                    content_disposition: content_disposition,
         | 
| 104 | 
            +
                    part_size: MINIMUM_UPLOAD_PART_SIZE,
         | 
| 105 | 
            +
                    metadata: custom_metadata,
         | 
| 106 | 
            +
                    **upload_options
         | 
| 107 | 
            +
                  ) do |out|
         | 
| 108 | 
            +
                    source_keys.each do |source_key|
         | 
| 109 | 
            +
                      stream(source_key) do |chunk|
         | 
| 110 | 
            +
                        IO.copy_stream(StringIO.new(chunk), out)
         | 
| 111 | 
            +
                      end
         | 
| 112 | 
            +
                    end
         | 
| 113 | 
            +
                  end
         | 
| 96 114 | 
             
                end
         | 
| 97 115 |  | 
| 98 116 | 
             
                private
         | 
| 99 | 
            -
                  def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
         | 
| 117 | 
            +
                  def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts)
         | 
| 100 118 | 
             
                    object_for(key).presigned_url :get, expires_in: expires_in.to_i,
         | 
| 101 119 | 
             
                      response_content_disposition: content_disposition_with(type: disposition, filename: filename),
         | 
| 102 | 
            -
                      response_content_type: content_type
         | 
| 120 | 
            +
                      response_content_type: content_type, **client_opts
         | 
| 103 121 | 
             
                  end
         | 
| 104 122 |  | 
| 105 | 
            -
                  def public_url(key, **)
         | 
| 106 | 
            -
                    object_for(key).public_url
         | 
| 123 | 
            +
                  def public_url(key, **client_opts)
         | 
| 124 | 
            +
                    object_for(key).public_url(**client_opts)
         | 
| 107 125 | 
             
                  end
         | 
| 108 126 |  | 
| 109 127 |  | 
| 110 128 | 
             
                  MAXIMUM_UPLOAD_PARTS_COUNT = 10000
         | 
| 111 129 | 
             
                  MINIMUM_UPLOAD_PART_SIZE   = 5.megabytes
         | 
| 112 130 |  | 
| 113 | 
            -
                  def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil)
         | 
| 114 | 
            -
                    object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, **upload_options)
         | 
| 131 | 
            +
                  def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil, custom_metadata: {})
         | 
| 132 | 
            +
                    object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, **upload_options)
         | 
| 115 133 | 
             
                  rescue Aws::S3::Errors::BadDigest
         | 
| 116 134 | 
             
                    raise ActiveStorage::IntegrityError
         | 
| 117 135 | 
             
                  end
         | 
| 118 136 |  | 
| 119 | 
            -
                  def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
         | 
| 137 | 
            +
                  def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {})
         | 
| 120 138 | 
             
                    part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
         | 
| 121 139 |  | 
| 122 | 
            -
                    object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
         | 
| 140 | 
            +
                    object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options) do |out|
         | 
| 123 141 | 
             
                      IO.copy_stream(io, out)
         | 
| 124 142 | 
             
                    end
         | 
| 125 143 | 
             
                  end
         | 
| @@ -143,5 +161,9 @@ module ActiveStorage | |
| 143 161 | 
             
                      offset += chunk_size
         | 
| 144 162 | 
             
                    end
         | 
| 145 163 | 
             
                  end
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                  def custom_metadata_headers(metadata)
         | 
| 166 | 
            +
                    metadata.transform_keys { |key| "x-amz-meta-#{key}" }
         | 
| 167 | 
            +
                  end
         | 
| 146 168 | 
             
              end
         | 
| 147 169 | 
             
            end
         |