Skip to content

Commit 80dfe91

Browse files
enable custom Blob#key configuration
1 parent 53000f3 commit 80dfe91

File tree

16 files changed

+232
-19
lines changed

16 files changed

+232
-19
lines changed

activestorage/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,21 @@
235235

236236

237237
Please check [6-1-stable](https://github.com/rails/rails/blob/6-1-stable/activestorage/CHANGELOG.md) for previous changes.
238+
239+
240+
* Add ability to customize the Blob#key per Model and attribute basis. Active Storage will save the Blob's attachment on the specified service at the configured key. You can also _interpolate_ values in it automatically with configurable procs.
241+
242+
```ruby
243+
# app/models/user.rb
244+
has_one_attached :avatar,
245+
key: ':tenant/users/:record_id/avatar/:blob_checksum'
246+
247+
# with the following configuration
248+
config.active_storage.key_interpolation_procs = {
249+
tenant: ->(record, blob) { Apartment::Tenant.current.parameterize },
250+
record_id: ->(record, blob) { record.id },
251+
blob_checksum: ->(record, blob) { blob.checksum }
252+
}
253+
```
254+
255+
*František Rokůsek*

activestorage/app/models/active_storage/blob.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
3737
self.table_name = "active_storage_blobs"
3838

3939
MINIMUM_TOKEN_LENGTH = 28
40+
KEY_PATH_SEPARATOR = "/"
4041

4142
has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
4243
store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
@@ -68,6 +69,11 @@ class ActiveStorage::Blob < ActiveStorage::Record
6869
end
6970
end
7071

72+
def move_to!(target_key)
73+
service.move(key, target_key)
74+
update_columns(key: target_key)
75+
end
76+
7177
class << self
7278
# You can use the signed ID of a blob to refer to it on the client side without fear of tampering.
7379
# This is particularly helpful for direct uploads where the client-side needs to refer to the blob
@@ -145,6 +151,24 @@ def scope_for_strict_loading # :nodoc:
145151
all
146152
end
147153
end
154+
155+
# Interpolates the custom storage key if needed and appends to it a unique_secure_token
156+
def generate_unique_interpolated_secure_key(key:, record:, blob:, length: MINIMUM_TOKEN_LENGTH)
157+
[
158+
interpolate(key: key, record: record, blob: blob),
159+
generate_unique_secure_token(length: length)
160+
].compact.join(KEY_PATH_SEPARATOR)
161+
end
162+
163+
# Interpolates configured variables into keys,
164+
# with procs coming from key_interpolation_procs configuration hash
165+
def interpolate(key:, record:, blob:)
166+
key.split(KEY_PATH_SEPARATOR).map do |key_part|
167+
key_part.gsub(/:(\w*)/) do
168+
ActiveStorage.key_interpolation_procs[$1.to_sym].call(record, blob)
169+
end
170+
end.join(KEY_PATH_SEPARATOR)
171+
end
148172
end
149173

150174
# Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.

activestorage/lib/active_storage.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module ActiveStorage
6363
mattr_accessor :urls_expire_in
6464

6565
mattr_accessor :routes_prefix, default: "/rails/active_storage"
66+
mattr_accessor :key_interpolation_procs, default: {}
6667
mattr_accessor :draw_routes, default: true
6768
mattr_accessor :resolve_model_to_route, default: :rails_storage_redirect
6869

activestorage/lib/active_storage/attached/changes/create_many.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# frozen_string_literal: true
22

33
module ActiveStorage
4-
class Attached::Changes::CreateMany # :nodoc:
5-
attr_reader :name, :record, :attachables
4+
class Attached::Changes::CreateMany #:nodoc:
5+
attr_reader :name, :record, :attachables, :key
66

7-
def initialize(name, record, attachables)
8-
@name, @record, @attachables = name, record, Array(attachables)
7+
def initialize(name, record, attachables, key)
8+
@name, @record, @attachables, @key = name, record, Array(attachables), key
99
blobs.each(&:identify_without_saving)
1010
attachments
1111
end
@@ -25,6 +25,14 @@ def upload
2525
def save
2626
assign_associated_attachments
2727
reset_associated_blobs
28+
29+
unless key.blank?
30+
blobs.each |blob| do
31+
blob.move_to!(
32+
ActiveStorage::Blob.generate_unique_interpolated_secure_key(key: key, record: record, blob: blob)
33+
)
34+
end
35+
end
2836
end
2937

3038
private
@@ -33,7 +41,7 @@ def subchanges
3341
end
3442

3543
def build_subchange_from(attachable)
36-
ActiveStorage::Attached::Changes::CreateOneOfMany.new(name, record, attachable)
44+
ActiveStorage::Attached::Changes::CreateOneOfMany.new(name, record, attachable, key)
3745
end
3846

3947
def assign_associated_attachments

activestorage/lib/active_storage/attached/changes/create_one.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
require "action_dispatch/http/upload"
55

66
module ActiveStorage
7-
class Attached::Changes::CreateOne # :nodoc:
8-
attr_reader :name, :record, :attachable
7+
class Attached::Changes::CreateOne #:nodoc:
8+
attr_reader :name, :record, :attachable, :key
99

10-
def initialize(name, record, attachable)
11-
@name, @record, @attachable = name, record, attachable
10+
def initialize(name, record, attachable, key)
11+
@name, @record, @attachable, @key = name, record, attachable, key
1212
blob.identify_without_saving
1313
end
1414

@@ -32,6 +32,12 @@ def upload
3232
def save
3333
record.public_send("#{name}_attachment=", attachment)
3434
record.public_send("#{name}_blob=", blob)
35+
36+
unless key.blank?
37+
blob.move_to!(
38+
ActiveStorage::Blob.generate_unique_interpolated_secure_key(key: key, record: record, blob: blob)
39+
)
40+
end
3541
end
3642

3743
private

activestorage/lib/active_storage/attached/model.rb

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module ActiveStorage
77
module Attached::Model
88
extend ActiveSupport::Concern
99

10+
KEY_PATH_SEPARATOR = "/"
11+
1012
class_methods do
1113
# Specifies the relation between a single attachment and the model.
1214
#
@@ -47,8 +49,17 @@ module Attached::Model
4749
# has_one_attached :avatar, strict_loading: true
4850
# end
4951
#
50-
def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
52+
# If you need the attachment to have a specific storage path, on a Model level (other than the global 'route_prefix'),
53+
# pass the +:key+ option. For instance:
54+
#
55+
# class User < ActiveRecord::Base
56+
# has_one_attached :avatar, key: 'tenant/:tenant_subdomain/users/:record_id/avatar'
57+
# end
58+
#
59+
#
60+
def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false, key: nil)
5161
validate_service_configuration(name, service)
62+
validate_key_interpolations(name, key)
5263

5364
generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
5465
# frozen_string_literal: true
@@ -62,7 +73,7 @@ def #{name}=(attachable)
6273
if attachable.nil?
6374
ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
6475
else
65-
ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
76+
ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable, "#{key}")
6677
end
6778
end
6879
CODE
@@ -126,8 +137,16 @@ def #{name}=(attachable)
126137
# has_many_attached :photos, strict_loading: true
127138
# end
128139
#
129-
def has_many_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
140+
# If you need the attachments to have a specific storage path, on a Model level (other than the global 'route_prefix'),
141+
# pass the +:key+ option. For instance:
142+
#
143+
# class User < ActiveRecord::Base
144+
# has_many_attached :photos, key: 'tenant/:tenant_subdomain/users/:record_id/photos'
145+
# end
146+
#
147+
def has_many_attached(name, dependent: :purge_later, service: nil, strict_loading: false, key: nil)
130148
validate_service_configuration(name, service)
149+
validate_key_interpolations(name, key)
131150

132151
generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
133152
# frozen_string_literal: true
@@ -142,7 +161,7 @@ def #{name}=(attachables)
142161
if Array(attachables).none?
143162
ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self)
144163
else
145-
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables)
164+
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables, "#{key}")
146165
end
147166
else
148167
ActiveSupport::Deprecation.warn \
@@ -153,7 +172,7 @@ def #{name}=(attachables)
153172
154173
if Array(attachables).any?
155174
attachment_changes["#{name}"] =
156-
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, #{name}.blobs + attachables)
175+
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, #{name}.blobs + attachables, "#{key}")
157176
end
158177
end
159178
end
@@ -215,6 +234,17 @@ def validate_service_configuration(association_name, service)
215234
end
216235
end
217236
end
237+
238+
def validate_key_interpolations(association_name, key)
239+
return if key.blank?
240+
241+
key.scan(/:(\w+)/) do
242+
key_part = $1.to_sym
243+
unless ActiveStorage.key_interpolation_procs.keys.include?(key_part)
244+
raise ArgumentError, "Cannot configure #{key_part} in interpolation key '#{key}' for #{name}##{association_name}"
245+
end
246+
end
247+
end
218248
end
219249

220250
def attachment_changes # :nodoc:

activestorage/lib/active_storage/engine.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class Engine < Rails::Engine # :nodoc:
9191
ActiveStorage.routes_prefix = app.config.active_storage.routes_prefix || "/rails/active_storage"
9292
ActiveStorage.draw_routes = app.config.active_storage.draw_routes != false
9393
ActiveStorage.resolve_model_to_route = app.config.active_storage.resolve_model_to_route || :rails_storage_redirect
94+
ActiveStorage.key_interpolation_procs = app.config.active_storage.key_interpolation_procs || {}
9495

9596
ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || []
9697
ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || []

activestorage/lib/active_storage/service.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ def exist?(key)
105105
raise NotImplementedError
106106
end
107107

108+
def move(source_key, target_key)
109+
raise NotImplementedError
110+
end
111+
108112
# Returns the URL for the file at the +key+. This returns a permanent URL for public files, and returns a
109113
# short-lived URL for private files. For private files you can provide the +disposition+ (+:inline+ or +:attachment+),
110114
# +filename+, and +content_type+ that you wish the file to be served with on request. Additionally, you can also provide

activestorage/lib/active_storage/service/azure_storage_service.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ def exist?(key)
8686
end
8787
end
8888

89+
def move(source_key, target_key)
90+
instrument :move, source_key: source_key, target_key: target_key do
91+
client.copy_blob(container, target_key, container, source_key)
92+
client.delete_blob(container, source_key)
93+
end
94+
end
95+
8996
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
9097
instrument :url, key: key do |payload|
9198
generated_url = signer.signed_uri(

activestorage/lib/active_storage/service/disk_service.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ def exist?(key)
7272
end
7373
end
7474

75+
def move(source_key, target_key)
76+
File.rename path_for(source_key), path_for(target_key)
77+
end
78+
7579
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
7680
instrument :url, key: key do |payload|
7781
verified_token_with_expiration = ActiveStorage.verifier.generate(

0 commit comments

Comments
 (0)