activestorage 6.1.6.1 → 7.0.3.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of activestorage might be problematic. Click here for more details.

Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -212
  3. data/README.md +25 -11
  4. data/app/assets/javascripts/activestorage.esm.js +844 -0
  5. data/app/assets/javascripts/activestorage.js +257 -376
  6. data/app/controllers/active_storage/base_controller.rb +0 -9
  7. data/app/controllers/active_storage/blobs/proxy_controller.rb +15 -4
  8. data/app/controllers/active_storage/blobs/redirect_controller.rb +6 -4
  9. data/app/controllers/active_storage/disk_controller.rb +1 -0
  10. data/app/controllers/active_storage/representations/base_controller.rb +5 -1
  11. data/app/controllers/active_storage/representations/proxy_controller.rb +7 -3
  12. data/app/controllers/active_storage/representations/redirect_controller.rb +6 -4
  13. data/app/controllers/concerns/active_storage/set_blob.rb +6 -2
  14. data/app/controllers/concerns/active_storage/set_current.rb +3 -3
  15. data/app/controllers/concerns/active_storage/streaming.rb +65 -0
  16. data/app/javascript/activestorage/ujs.js +1 -1
  17. data/app/models/active_storage/attachment.rb +35 -2
  18. data/app/models/active_storage/blob/representable.rb +7 -5
  19. data/app/models/active_storage/blob.rb +92 -36
  20. data/app/models/active_storage/current.rb +12 -2
  21. data/app/models/active_storage/preview.rb +6 -4
  22. data/app/models/active_storage/record.rb +1 -1
  23. data/app/models/active_storage/variant.rb +3 -6
  24. data/app/models/active_storage/variant_record.rb +2 -0
  25. data/app/models/active_storage/variant_with_record.rb +9 -5
  26. data/app/models/active_storage/variation.rb +2 -2
  27. data/config/routes.rb +10 -10
  28. data/db/migrate/20170806125915_create_active_storage_tables.rb +32 -11
  29. data/db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb +4 -0
  30. data/db/update_migrate/20191206030411_create_active_storage_variant_records.rb +17 -2
  31. data/db/update_migrate/20211119233751_remove_not_null_on_active_storage_blobs_checksum.rb +7 -0
  32. data/lib/active_storage/analyzer/audio_analyzer.rb +65 -0
  33. data/lib/active_storage/analyzer/image_analyzer/image_magick.rb +39 -0
  34. data/lib/active_storage/analyzer/image_analyzer/vips.rb +49 -0
  35. data/lib/active_storage/analyzer/image_analyzer.rb +2 -30
  36. data/lib/active_storage/analyzer/video_analyzer.rb +27 -12
  37. data/lib/active_storage/analyzer.rb +8 -4
  38. data/lib/active_storage/attached/changes/create_many.rb +7 -3
  39. data/lib/active_storage/attached/changes/create_one.rb +1 -1
  40. data/lib/active_storage/attached/changes/create_one_of_many.rb +1 -1
  41. data/lib/active_storage/attached/changes/delete_many.rb +1 -1
  42. data/lib/active_storage/attached/changes/delete_one.rb +1 -1
  43. data/lib/active_storage/attached/changes/detach_many.rb +18 -0
  44. data/lib/active_storage/attached/changes/detach_one.rb +24 -0
  45. data/lib/active_storage/attached/changes/purge_many.rb +27 -0
  46. data/lib/active_storage/attached/changes/purge_one.rb +27 -0
  47. data/lib/active_storage/attached/changes.rb +7 -1
  48. data/lib/active_storage/attached/many.rb +27 -15
  49. data/lib/active_storage/attached/model.rb +35 -7
  50. data/lib/active_storage/attached/one.rb +32 -27
  51. data/lib/active_storage/downloader.rb +4 -4
  52. data/lib/active_storage/engine.rb +45 -1
  53. data/lib/active_storage/fixture_set.rb +76 -0
  54. data/lib/active_storage/gem_version.rb +4 -4
  55. data/lib/active_storage/previewer.rb +4 -4
  56. data/lib/active_storage/reflection.rb +12 -2
  57. data/lib/active_storage/service/azure_storage_service.rb +28 -6
  58. data/lib/active_storage/service/configurator.rb +1 -1
  59. data/lib/active_storage/service/disk_service.rb +24 -19
  60. data/lib/active_storage/service/gcs_service.rb +109 -11
  61. data/lib/active_storage/service/mirror_service.rb +2 -2
  62. data/lib/active_storage/service/registry.rb +1 -1
  63. data/lib/active_storage/service/s3_service.rb +37 -15
  64. data/lib/active_storage/service.rb +13 -5
  65. data/lib/active_storage/transformers/image_processing_transformer.rb +1 -1
  66. data/lib/active_storage/transformers/transformer.rb +1 -1
  67. data/lib/active_storage/version.rb +1 -1
  68. data/lib/active_storage.rb +4 -0
  69. metadata +24 -14
  70. data/app/controllers/concerns/active_storage/set_headers.rb +0 -12
@@ -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) #:doc:
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) #:doc:
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 #:doc:
86
+ def logger # :doc:
87
87
  ActiveStorage.logger
88
88
  end
89
89
 
90
- def tmpdir #:doc:
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 < ActiveRecord::Reflection::MacroReflection #:nodoc:
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 < ActiveRecord::Reflection::MacroReflection #:nodoc:
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", ">= 1.1"
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorage
4
- class Service::Configurator #:nodoc:
4
+ class Service::Configurator # :nodoc:
5
5
  attr_reader :configurations
6
6
 
7
7
  def self.build(service_name, configurations)
@@ -2,14 +2,14 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "pathname"
5
- require "digest/md5"
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
- attr_reader :root
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
- generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
90
-
91
- payload[:url] = generated_url
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) #:nodoc:
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
- current_uri = URI.parse(current_host)
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 current_host
168
- ActiveStorage::Current.host
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
- generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
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
- file_for(key).signed_url expires: expires_in, query: {
104
- "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
105
- "response-content-type" => content_type
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) #:nodoc:
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 }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorage
4
- class Service::Registry #:nodoc:
4
+ class Service::Registry # :nodoc:
5
5
  def initialize(configurations)
6
6
  @configurations = configurations.deep_symbolize_keys
7
7
  @services = {}
@@ -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
@@ -35,8 +35,8 @@ module ActiveStorage
35
35
  # can configure the service to use like this:
36
36
  #
37
37
  # ActiveStorage::Blob.service = ActiveStorage::Service.configure(
38
- # :Disk,
39
- # root: Pathname("/foo/bar/storage")
38
+ # :local,
39
+ # { local: {service: "Disk", root: Pathname("/tmp/foo/storage") } }
40
40
  # )
41
41
  class Service
42
42
  extend ActiveSupport::Autoload
@@ -57,7 +57,7 @@ module ActiveStorage
57
57
  # Passes the configurator and all of the service's config as keyword args.
58
58
  #
59
59
  # See MirrorService for an example.
60
- def build(configurator:, name:, service: nil, **service_config) #:nodoc:
60
+ def build(configurator:, name:, service: nil, **service_config) # :nodoc:
61
61
  new(**service_config).tap do |service_instance|
62
62
  service_instance.name = name
63
63
  end
@@ -90,6 +90,11 @@ module ActiveStorage
90
90
  ActiveStorage::Downloader.new(self).open(*args, **options, &block)
91
91
  end
92
92
 
93
+ # Concatenate multiple files into a single "composed" file.
94
+ def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
95
+ raise NotImplementedError
96
+ end
97
+
93
98
  # Delete the file at the +key+.
94
99
  def delete(key)
95
100
  raise NotImplementedError
@@ -128,12 +133,12 @@ module ActiveStorage
128
133
  # The URL will be valid for the amount of seconds specified in +expires_in+.
129
134
  # You must also provide the +content_type+, +content_length+, and +checksum+ of the file
130
135
  # that will be uploaded. All these attributes will be validated by the service upon upload.
131
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
136
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
132
137
  raise NotImplementedError
133
138
  end
134
139
 
135
140
  # Returns a Hash of headers for +url_for_direct_upload+ requests.
136
- def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
141
+ def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:, custom_metadata: {})
137
142
  {}
138
143
  end
139
144
 
@@ -150,6 +155,9 @@ module ActiveStorage
150
155
  raise NotImplementedError
151
156
  end
152
157
 
158
+ def custom_metadata_headers(metadata)
159
+ raise NotImplementedError
160
+ end
153
161
 
154
162
  def instrument(operation, payload = {}, &block)
155
163
  ActiveSupport::Notifications.instrument(
@@ -38,7 +38,7 @@ module ActiveStorage
38
38
  if name.to_s == "combine_options"
39
39
  raise ArgumentError, <<~ERROR.squish
40
40
  Active Storage's ImageProcessing transformer doesn't support :combine_options,
41
- as it always generates a single ImageMagick command.
41
+ as it always generates a single command.
42
42
  ERROR
43
43
  end
44
44
 
@@ -31,7 +31,7 @@ module ActiveStorage
31
31
  private
32
32
  # Returns an open Tempfile containing a transformed image in the given +format+.
33
33
  # All subclasses implement this method.
34
- def process(file, format:) #:doc:
34
+ def process(file, format:) # :doc:
35
35
  raise NotImplementedError
36
36
  end
37
37
  end
@@ -3,7 +3,7 @@
3
3
  require_relative "gem_version"
4
4
 
5
5
  module ActiveStorage
6
- # Returns the version of the currently loaded ActiveStorage as a <tt>Gem::Version</tt>
6
+ # Returns the currently loaded version of Active Storage as a <tt>Gem::Version</tt>.
7
7
  def self.version
8
8
  gem_version
9
9
  end
@@ -37,6 +37,7 @@ module ActiveStorage
37
37
  extend ActiveSupport::Autoload
38
38
 
39
39
  autoload :Attached
40
+ autoload :FixtureSet
40
41
  autoload :Service
41
42
  autoload :Previewer
42
43
  autoload :Analyzer
@@ -350,6 +351,7 @@ module ActiveStorage
350
351
  mattr_accessor :unsupported_image_processing_arguments
351
352
 
352
353
  mattr_accessor :service_urls_expire_in, default: 5.minutes
354
+ mattr_accessor :urls_expire_in
353
355
 
354
356
  mattr_accessor :routes_prefix, default: "/rails/active_storage"
355
357
  mattr_accessor :draw_routes, default: true
@@ -360,6 +362,8 @@ module ActiveStorage
360
362
 
361
363
  mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2"
362
364
 
365
+ mattr_accessor :silence_invalid_content_types_warning, default: false
366
+
363
367
  module Transformers
364
368
  extend ActiveSupport::Autoload
365
369