Skip to content

Commit 05ec88c

Browse files
authored
Merge pull request rails#43294 from joshuamsager/joshuamsager/as-custom-metadata
[ActiveStorage] Custom Metadata
2 parents 9606aa7 + e106a4a commit 05ec88c

File tree

12 files changed

+140
-40
lines changed

12 files changed

+140
-40
lines changed

activestorage/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Setting custom metadata on blobs are now persisted to remote storage.
2+
3+
*joshuamsager*
4+
15
* Support direct uploads to multiple services.
26

37
*Dmitry Tsepelev*

activestorage/app/models/active_storage/blob.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
5252
self.service_name ||= self.class.service&.name
5353
end
5454

55-
after_update_commit :update_service_metadata, if: :content_type_previously_changed?
55+
after_update_commit :update_service_metadata, if: -> { content_type_previously_changed? || metadata_previously_changed? }
5656

5757
before_destroy(prepend: true) do
5858
raise ActiveRecord::InvalidForeignKey if attachments.exists?
@@ -168,6 +168,14 @@ def filename
168168
ActiveStorage::Filename.new(self[:filename])
169169
end
170170

171+
def custom_metadata
172+
self[:metadata][:custom] || {}
173+
end
174+
175+
def custom_metadata=(metadata)
176+
self[:metadata] = self[:metadata].merge(custom: metadata)
177+
end
178+
171179
# Returns true if the content_type of this blob is in the image range, like image/png.
172180
def image?
173181
content_type.start_with?("image")
@@ -200,12 +208,12 @@ def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline,
200208
# Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
201209
# short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
202210
def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
203-
service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
211+
service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata
204212
end
205213

206214
# Returns a Hash of headers for +service_url_for_direct_upload+ requests.
207215
def service_headers_for_direct_upload
208-
service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
216+
service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata
209217
end
210218

211219
def content_type_for_serving # :nodoc:
@@ -362,11 +370,11 @@ def web_image?
362370

363371
def service_metadata
364372
if forcibly_serve_as_binary?
365-
{ content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
373+
{ content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename, custom_metadata: custom_metadata }
366374
elsif !allowed_inline?
367-
{ content_type: content_type, disposition: :attachment, filename: filename }
375+
{ content_type: content_type, disposition: :attachment, filename: filename, custom_metadatata: custom_metadata }
368376
else
369-
{ content_type: content_type }
377+
{ content_type: content_type, custom_metadata: custom_metadata }
370378
end
371379
end
372380

activestorage/lib/active_storage/service.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,12 @@ def url(key, **options)
128128
# The URL will be valid for the amount of seconds specified in +expires_in+.
129129
# You must also provide the +content_type+, +content_length+, and +checksum+ of the file
130130
# 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:)
131+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
132132
raise NotImplementedError
133133
end
134134

135135
# Returns a Hash of headers for +url_for_direct_upload+ requests.
136-
def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
136+
def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:, custom_metadata: {})
137137
{}
138138
end
139139

@@ -150,6 +150,9 @@ def public_url(key, **)
150150
raise NotImplementedError
151151
end
152152

153+
def custom_metadata_headers(metadata)
154+
raise NotImplementedError
155+
end
153156

154157
def instrument(operation, payload = {}, &block)
155158
ActiveSupport::Notifications.instrument(

activestorage/lib/active_storage/service/azure_storage_service.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ def initialize(storage_account_name:, storage_access_key:, container:, public: f
1919
@public = public
2020
end
2121

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: {}, **)
2323
instrument :upload, key: key, checksum: checksum do
2424
handle_errors do
2525
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
2626

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)
2828
end
2929
end
3030
end
@@ -86,7 +86,7 @@ def exist?(key)
8686
end
8787
end
8888

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: {})
9090
instrument :url, key: key do |payload|
9191
generated_url = signer.signed_uri(
9292
uri_for(key), false,
@@ -101,10 +101,10 @@ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, chec
101101
end
102102
end
103103

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:, **)
105105
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
106106

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) }
108108
end
109109

110110
private
@@ -166,5 +166,9 @@ def handle_errors
166166
raise
167167
end
168168
end
169+
170+
def custom_metadata_headers(metadata)
171+
metadata.transform_keys { |key| "x-ms-meta-#{key}" }
172+
end
169173
end
170174
end

activestorage/lib/active_storage/service/disk_service.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def exist?(key)
7272
end
7373
end
7474

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: {})
7676
instrument :url, key: key do |payload|
7777
verified_token_with_expiration = ActiveStorage.verifier.generate(
7878
{

activestorage/lib/active_storage/service/gcs_service.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ def initialize(public: false, **config)
1616
@public = public
1717
end
1818

19-
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: {})
2020
instrument :upload, key: key, checksum: checksum do
2121
# GCS's signed URLs don't include params such as response-content-type response-content_disposition
2222
# in the signature, which means an attacker can modify them and bypass our effort to force these to
2323
# binary and attachment when the file's content type requires it. The only way to force them is to
2424
# store them as object's metadata.
2525
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
26-
bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], 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)
2727
rescue Google::Cloud::InvalidArgumentError
2828
raise ActiveStorage::IntegrityError
2929
end
@@ -43,11 +43,12 @@ def download(key, &block)
4343
end
4444
end
4545

46-
def update_metadata(key, content_type:, disposition: nil, filename: nil)
46+
def update_metadata(key, content_type:, disposition: nil, filename: nil, custom_metadata: {})
4747
instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
4848
file_for(key).update do |file|
4949
file.content_type = content_type
5050
file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
51+
file.metadata = custom_metadata
5152
end
5253
end
5354
end
@@ -86,7 +87,7 @@ def exist?(key)
8687
end
8788
end
8889

89-
def url_for_direct_upload(key, expires_in:, checksum:, **)
90+
def url_for_direct_upload(key, expires_in:, checksum:, custom_metadata: {}, **)
9091
instrument :url, key: key do |payload|
9192
headers = {}
9293
version = :v2
@@ -99,6 +100,8 @@ def url_for_direct_upload(key, expires_in:, checksum:, **)
99100
version = :v4
100101
end
101102

103+
headers.merge!(custom_metadata_headers(custom_metadata))
104+
102105
args = {
103106
content_md5: checksum,
104107
expires: expires_in,
@@ -120,11 +123,10 @@ def url_for_direct_upload(key, expires_in:, checksum:, **)
120123
end
121124
end
122125

123-
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: {}, **)
124127
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
125128

126-
headers = { "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
127-
129+
headers = { "Content-MD5" => checksum, "Content-Disposition" => content_disposition, **custom_metadata_headers(custom_metadata) }
128130
if @config[:cache_control].present?
129131
headers["Cache-Control"] = @config[:cache_control]
130132
end
@@ -223,5 +225,9 @@ def signer
223225
response.signed_blob
224226
end
225227
end
228+
229+
def custom_metadata_headers(metadata)
230+
metadata.transform_keys { |key| "x-goog-meta-#{key}" }
231+
end
226232
end
227233
end

activestorage/lib/active_storage/service/s3_service.rb

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ def initialize(bucket:, upload: {}, public: false, **options)
2323
@upload_options[:acl] = "public-read" if public?
2424
end
2525

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: {}, **)
2727
instrument :upload, key: key, checksum: checksum do
2828
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
2929

3030
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
3232
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
3434
end
3535
end
3636
end
@@ -77,22 +77,22 @@ def exist?(key)
7777
end
7878
end
7979

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: {})
8181
instrument :url, key: key do |payload|
8282
generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
8383
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
8585

8686
payload[:url] = generated_url
8787

8888
generated_url
8989
end
9090
end
9191

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: {}, **)
9393
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
9494

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) }
9696
end
9797

9898
private
@@ -110,16 +110,16 @@ def public_url(key, **client_opts)
110110
MAXIMUM_UPLOAD_PARTS_COUNT = 10000
111111
MINIMUM_UPLOAD_PART_SIZE = 5.megabytes
112112

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)
113+
def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil, custom_metadata: {})
114+
object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, **upload_options)
115115
rescue Aws::S3::Errors::BadDigest
116116
raise ActiveStorage::IntegrityError
117117
end
118118

119-
def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
119+
def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {})
120120
part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
121121

122-
object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
122+
object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options) do |out|
123123
IO.copy_stream(io, out)
124124
end
125125
end
@@ -143,5 +143,9 @@ def stream(key)
143143
offset += chunk_size
144144
end
145145
end
146+
147+
def custom_metadata_headers(metadata)
148+
metadata.transform_keys { |key| "x-amz-meta-#{key}" }
149+
end
146150
end
147151
end

activestorage/test/controllers/direct_uploads_controller_test.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ class ActiveStorage::S3DirectUploadsControllerTest < ActionDispatch::Integration
2222
"my_key_1": "my_value_1",
2323
"my_key_2": "my_value_2",
2424
"platform": "my_platform",
25-
"library_ID": "12345"
25+
"library_ID": "12345",
26+
custom: {
27+
"my_key_3": "my_value_3"
28+
}
2629
}
2730

2831
ActiveStorage::DirectUploadToken.stub(:verify_direct_upload_token, "s3") do
@@ -39,7 +42,7 @@ class ActiveStorage::S3DirectUploadsControllerTest < ActionDispatch::Integration
3942
assert_equal "text/plain", details["content_type"]
4043
assert_match SERVICE_CONFIGURATIONS[:s3][:bucket], details["direct_upload"]["url"]
4144
assert_match(/s3(-[-a-z0-9]+)?\.(\S+)?amazonaws\.com/, details["direct_upload"]["url"])
42-
assert_equal({ "Content-Type" => "text/plain", "Content-MD5" => checksum, "Content-Disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt" }, details["direct_upload"]["headers"])
45+
assert_equal({ "Content-Type" => "text/plain", "Content-MD5" => checksum, "Content-Disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", "x-amz-meta-my_key_3" => "my_value_3" }, details["direct_upload"]["headers"])
4346
end
4447
end
4548
end
@@ -67,7 +70,10 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio
6770
"my_key_1": "my_value_1",
6871
"my_key_2": "my_value_2",
6972
"platform": "my_platform",
70-
"library_ID": "12345"
73+
"library_ID": "12345",
74+
custom: {
75+
"my_key_3": "my_value_3"
76+
}
7177
}
7278

7379
ActiveStorage::DirectUploadToken.stub(:verify_direct_upload_token, "gcs") do
@@ -83,7 +89,7 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio
8389
assert_equal metadata, details["metadata"].transform_keys(&:to_sym)
8490
assert_equal "text/plain", details["content_type"]
8591
assert_match %r{storage\.googleapis\.com/#{@config[:bucket]}}, details["direct_upload"]["url"]
86-
assert_equal({ "Content-MD5" => checksum, "Content-Disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt" }, details["direct_upload"]["headers"])
92+
assert_equal({ "Content-MD5" => checksum, "Content-Disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", "x-goog-meta-my_key_3" => "my_value_3" }, details["direct_upload"]["headers"])
8793
end
8894
end
8995
end

activestorage/test/models/blob_test.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,31 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase
259259
test "updating the content_type updates service metadata" do
260260
blob = directly_upload_file_blob(filename: "racecar.jpg", content_type: "application/octet-stream")
261261

262-
expected_arguments = [blob.key, content_type: "image/jpeg"]
262+
expected_arguments = [blob.key, content_type: "image/jpeg", custom_metadata: {}]
263263

264264
assert_called_with(blob.service, :update_metadata, expected_arguments) do
265265
blob.update!(content_type: "image/jpeg")
266266
end
267267
end
268268

269+
test "updating the metadata updates service metadata" do
270+
blob = directly_upload_file_blob(filename: "racecar.jpg", content_type: "application/octet-stream")
271+
272+
expected_arguments = [
273+
blob.key,
274+
{
275+
content_type: "application/octet-stream",
276+
disposition: :attachment,
277+
filename: blob.filename,
278+
custom_metadatata: { "test" => true }
279+
}
280+
]
281+
282+
assert_called_with(blob.service, :update_metadata, expected_arguments) do
283+
blob.update!(metadata: { custom: { "test" => true } })
284+
end
285+
end
286+
269287
test "scope_for_strict_loading adds includes only when track_variants and strict_loading_by_default" do
270288
assert_empty(
271289
ActiveStorage::Blob.scope_for_strict_loading.includes_values,

activestorage/test/service/azure_storage_service_test.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ class ActiveStorage::Service::AzureStorageServiceTest < ActiveSupport::TestCase
8181
@service.delete key
8282
end
8383

84+
test "upload with custom_metadata" do
85+
key = SecureRandom.base58(24)
86+
data = "Foobar"
87+
88+
@service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), custom_metadata: { "foo" => "baz" })
89+
url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html"))
90+
91+
response = Net::HTTP.get_response(URI(url))
92+
assert_equal("baz", response["x-ms-meta-foo"])
93+
ensure
94+
@service.delete key
95+
end
96+
8497
test "signed URL generation" do
8598
url = @service.url(@key, expires_in: 5.minutes,
8699
disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png")

0 commit comments

Comments
 (0)