shrine 2.13.0 → 2.14.0

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

Potentially problematic release.


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

Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +20 -16
  4. data/doc/creating_storages.md +0 -21
  5. data/doc/design.md +1 -0
  6. data/doc/direct_s3.md +26 -15
  7. data/doc/metadata.md +67 -22
  8. data/doc/multiple_files.md +3 -3
  9. data/doc/processing.md +1 -1
  10. data/doc/retrieving_uploads.md +184 -0
  11. data/lib/shrine.rb +268 -900
  12. data/lib/shrine/attacher.rb +271 -0
  13. data/lib/shrine/attachment.rb +97 -0
  14. data/lib/shrine/plugins.rb +29 -0
  15. data/lib/shrine/plugins/_urlsafe_serialization.rb +182 -0
  16. data/lib/shrine/plugins/activerecord.rb +16 -14
  17. data/lib/shrine/plugins/add_metadata.rb +58 -24
  18. data/lib/shrine/plugins/backgrounding.rb +6 -1
  19. data/lib/shrine/plugins/cached_attachment_data.rb +9 -9
  20. data/lib/shrine/plugins/copy.rb +12 -8
  21. data/lib/shrine/plugins/data_uri.rb +23 -20
  22. data/lib/shrine/plugins/default_url_options.rb +5 -4
  23. data/lib/shrine/plugins/determine_mime_type.rb +24 -23
  24. data/lib/shrine/plugins/download_endpoint.rb +61 -73
  25. data/lib/shrine/plugins/migration_helpers.rb +17 -17
  26. data/lib/shrine/plugins/module_include.rb +9 -8
  27. data/lib/shrine/plugins/presign_endpoint.rb +13 -7
  28. data/lib/shrine/plugins/processing.rb +1 -1
  29. data/lib/shrine/plugins/rack_response.rb +128 -36
  30. data/lib/shrine/plugins/refresh_metadata.rb +20 -5
  31. data/lib/shrine/plugins/remote_url.rb +8 -8
  32. data/lib/shrine/plugins/remove_attachment.rb +9 -9
  33. data/lib/shrine/plugins/sequel.rb +21 -18
  34. data/lib/shrine/plugins/tempfile.rb +68 -0
  35. data/lib/shrine/plugins/upload_endpoint.rb +3 -2
  36. data/lib/shrine/plugins/upload_options.rb +7 -6
  37. data/lib/shrine/plugins/validation_helpers.rb +2 -1
  38. data/lib/shrine/storage/file_system.rb +20 -17
  39. data/lib/shrine/storage/linter.rb +0 -7
  40. data/lib/shrine/storage/s3.rb +159 -50
  41. data/lib/shrine/uploaded_file.rb +258 -0
  42. data/lib/shrine/version.rb +1 -1
  43. data/shrine.gemspec +7 -19
  44. metadata +41 -21
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shrine
4
+ # Core class which handles attaching files to model instances.
5
+ # Base implementation is defined in InstanceMethods and ClassMethods.
6
+ class Attacher
7
+ @shrine_class = ::Shrine
8
+
9
+ module ClassMethods
10
+ # Returns the Shrine class that this attacher class is namespaced
11
+ # under.
12
+ attr_accessor :shrine_class
13
+
14
+ # Since Attacher is anonymously subclassed when Shrine is subclassed,
15
+ # and then assigned to a constant of the Shrine subclass, make inspect
16
+ # reflect the likely name for the class.
17
+ def inspect
18
+ "#{shrine_class.inspect}::Attacher"
19
+ end
20
+
21
+ # Block that is executed in context of Shrine::Attacher during
22
+ # validation. Example:
23
+ #
24
+ # Shrine::Attacher.validate do
25
+ # if get.size > 5*1024*1024
26
+ # errors << "is too big (max is 5 MB)"
27
+ # end
28
+ # end
29
+ def validate(&block)
30
+ define_method(:validate_block, &block)
31
+ private :validate_block
32
+ end
33
+ end
34
+
35
+ module InstanceMethods
36
+ # Returns the uploader that is used for the temporary storage.
37
+ attr_reader :cache
38
+
39
+ # Returns the uploader that is used for the permanent storage.
40
+ attr_reader :store
41
+
42
+ # Returns the context that will be sent to the uploader when uploading
43
+ # and deleting. Can be modified with additional data to be sent to the
44
+ # uploader.
45
+ attr_reader :context
46
+
47
+ # Returns an array of validation errors created on file assignment in
48
+ # the `Attacher.validate` block.
49
+ attr_reader :errors
50
+
51
+ # Initializes the necessary attributes.
52
+ def initialize(record, name, cache: :cache, store: :store)
53
+ @cache = shrine_class.new(cache)
54
+ @store = shrine_class.new(store)
55
+ @context = {record: record, name: name}
56
+ @errors = []
57
+ end
58
+
59
+ # Returns the model instance associated with the attacher.
60
+ def record; context[:record]; end
61
+
62
+ # Returns the attachment name associated with the attacher.
63
+ def name; context[:name]; end
64
+
65
+ # Receives the attachment value from the form. It can receive an
66
+ # already cached file as a JSON string, otherwise it assumes that it's
67
+ # an IO object and uploads it to the temporary storage. The cached file
68
+ # is then written to the attachment attribute in the JSON format.
69
+ def assign(value, **options)
70
+ if value.is_a?(String)
71
+ return if value == "" || !cached?(uploaded_file(value))
72
+ assign_cached(uploaded_file(value))
73
+ else
74
+ uploaded_file = cache!(value, action: :cache, **options) if value
75
+ set(uploaded_file)
76
+ end
77
+ end
78
+
79
+ # Accepts a Shrine::UploadedFile object and writes it to the attachment
80
+ # attribute. It then runs file validations, and records that the
81
+ # attachment has changed.
82
+ def set(uploaded_file)
83
+ file = get
84
+ @old = file unless uploaded_file == file
85
+ _set(uploaded_file)
86
+ validate
87
+ end
88
+
89
+ # Runs the validations defined by `Attacher.validate`.
90
+ def validate
91
+ errors.clear
92
+ validate_block if get
93
+ end
94
+
95
+ # Returns true if a new file has been attached.
96
+ def changed?
97
+ instance_variable_defined?(:@old)
98
+ end
99
+ alias attached? changed?
100
+
101
+ # Plugins can override this if they want something to be done before
102
+ # save.
103
+ def save
104
+ end
105
+
106
+ # Deletes the old file and promotes the new one. Typically this should
107
+ # be called after saving the model instance.
108
+ def finalize
109
+ return if !instance_variable_defined?(:@old)
110
+ replace
111
+ remove_instance_variable(:@old)
112
+ _promote(action: :store) if cached?
113
+ end
114
+
115
+ # Delegates to #promote, overriden for backgrounding.
116
+ def _promote(uploaded_file = get, **options)
117
+ promote(uploaded_file, **options)
118
+ end
119
+
120
+ # Uploads the cached file to store, and writes the stored file to the
121
+ # attachment attribute.
122
+ def promote(uploaded_file = get, **options)
123
+ stored_file = store!(uploaded_file, **options)
124
+ result = swap(stored_file) or _delete(stored_file, action: :abort)
125
+ result
126
+ end
127
+
128
+ # Calls #update, overriden in ORM plugins, and returns true if the
129
+ # attachment was successfully updated.
130
+ def swap(uploaded_file)
131
+ update(uploaded_file)
132
+ uploaded_file if uploaded_file == get
133
+ end
134
+
135
+ # Deletes the previous attachment that was replaced, typically called
136
+ # after the model instance is saved with the new attachment.
137
+ def replace
138
+ _delete(@old, action: :replace) if @old && !cached?(@old)
139
+ end
140
+
141
+ # Deletes the current attachment, typically called after destroying the
142
+ # record.
143
+ def destroy
144
+ file = get
145
+ _delete(file, action: :destroy) if file && !cached?(file)
146
+ end
147
+
148
+ # Delegates to #delete!, overriden for backgrounding.
149
+ def _delete(uploaded_file, **options)
150
+ delete!(uploaded_file, **options)
151
+ end
152
+
153
+ # Returns the URL to the attached file if it's present. It forwards any
154
+ # given URL options to the storage.
155
+ def url(**options)
156
+ get.url(**options) if read
157
+ end
158
+
159
+ # Returns true if attachment is present and cached.
160
+ def cached?(file = get)
161
+ file && cache.uploaded?(file)
162
+ end
163
+
164
+ # Returns true if attachment is present and stored.
165
+ def stored?(file = get)
166
+ file && store.uploaded?(file)
167
+ end
168
+
169
+ # Returns a Shrine::UploadedFile instantiated from the data written to
170
+ # the attachment attribute.
171
+ def get
172
+ uploaded_file(read) if read
173
+ end
174
+
175
+ # Reads from the `<attachment>_data` attribute on the model instance.
176
+ # It returns nil if the value is blank.
177
+ def read
178
+ value = record.send(data_attribute)
179
+ convert_after_read(value) unless value.nil? || value.empty?
180
+ end
181
+
182
+ # Uploads the file using the #cache uploader, passing the #context.
183
+ def cache!(io, **options)
184
+ Shrine.deprecation("Sending :phase to Attacher#cache! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
185
+ cache.upload(io, context.merge(_equalize_phase_and_action(options)))
186
+ end
187
+
188
+ # Uploads the file using the #store uploader, passing the #context.
189
+ def store!(io, **options)
190
+ Shrine.deprecation("Sending :phase to Attacher#store! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
191
+ store.upload(io, context.merge(_equalize_phase_and_action(options)))
192
+ end
193
+
194
+ # Deletes the file using the uploader, passing the #context.
195
+ def delete!(uploaded_file, **options)
196
+ Shrine.deprecation("Sending :phase to Attacher#delete! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
197
+ store.delete(uploaded_file, context.merge(_equalize_phase_and_action(options)))
198
+ end
199
+
200
+ # Enhances `Shrine.uploaded_file` with the ability to recognize uploaded
201
+ # files as JSON strings.
202
+ def uploaded_file(object, &block)
203
+ shrine_class.uploaded_file(object, &block)
204
+ end
205
+
206
+ # The name of the attribute on the model instance that is used to store
207
+ # the attachment data. Defaults to `<attachment>_data`.
208
+ def data_attribute
209
+ :"#{name}_data"
210
+ end
211
+
212
+ # Returns the Shrine class that this attacher's class is namespaced
213
+ # under.
214
+ def shrine_class
215
+ self.class.shrine_class
216
+ end
217
+
218
+ private
219
+
220
+ # Assigns a cached file.
221
+ def assign_cached(cached_file)
222
+ set(cached_file)
223
+ end
224
+
225
+ # Writes the uploaded file to the attachment attribute. Overriden in ORM
226
+ # plugins to additionally save the model instance.
227
+ def update(uploaded_file)
228
+ _set(uploaded_file)
229
+ end
230
+
231
+ # Performs validation actually.
232
+ # This method is redefined with `Attacher.validate`.
233
+ def validate_block
234
+ end
235
+
236
+ # Converts the UploadedFile to a data hash and writes it to the
237
+ # attribute.
238
+ def _set(uploaded_file)
239
+ data = convert_to_data(uploaded_file) if uploaded_file
240
+ write(data ? convert_before_write(data) : nil)
241
+ end
242
+
243
+ # Writes to the `<attachment>_data` attribute on the model instance.
244
+ def write(value)
245
+ record.send(:"#{data_attribute}=", value)
246
+ end
247
+
248
+ # Returns the data hash of the given UploadedFile.
249
+ def convert_to_data(uploaded_file)
250
+ uploaded_file.data
251
+ end
252
+
253
+ # Returns the hash value dumped to JSON.
254
+ def convert_before_write(value)
255
+ value.to_json
256
+ end
257
+
258
+ # Returns the read value unchanged.
259
+ def convert_after_read(value)
260
+ value
261
+ end
262
+
263
+ # Temporary method used for transitioning from :phase to :action.
264
+ def _equalize_phase_and_action(options)
265
+ options[:phase] = options[:action] if options.key?(:action)
266
+ options[:action] = options[:phase] if options.key?(:phase)
267
+ options
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shrine
4
+ # Core class which creates attachment modules for specified attribute names
5
+ # that are included into model classes.
6
+ # Base implementation is defined in InstanceMethods and ClassMethods.
7
+ class Attachment < Module
8
+ @shrine_class = ::Shrine
9
+
10
+ module ClassMethods
11
+ # Returns the Shrine class that this attachment class is
12
+ # namespaced under.
13
+ attr_accessor :shrine_class
14
+
15
+ # Since Attachment is anonymously subclassed when Shrine is subclassed,
16
+ # and then assigned to a constant of the Shrine subclass, make inspect
17
+ # reflect the likely name for the class.
18
+ def inspect
19
+ "#{shrine_class.inspect}::Attachment"
20
+ end
21
+ end
22
+
23
+ module InstanceMethods
24
+ # Instantiates an attachment module for a given attribute name, which
25
+ # can then be included to a model class. Second argument will be passed
26
+ # to an attacher module.
27
+ def initialize(name, **options)
28
+ @name = name.to_sym
29
+ @options = options
30
+
31
+ define_attachment_methods!
32
+ end
33
+
34
+ # Defines attachment methods for the specified attachment name. These
35
+ # methods will be added to any model that includes this module.
36
+ def define_attachment_methods!
37
+ attachment = self
38
+ name = attachment_name
39
+
40
+ define_method :"#{name}_attacher" do |**options|
41
+ if !instance_variable_get(:"@#{name}_attacher") || options.any?
42
+ instance_variable_set(:"@#{name}_attacher", attachment.build_attacher(self, options))
43
+ else
44
+ instance_variable_get(:"@#{name}_attacher")
45
+ end
46
+ end
47
+
48
+ define_method :"#{name}=" do |value|
49
+ send(:"#{name}_attacher").assign(value)
50
+ end
51
+
52
+ define_method :"#{name}" do
53
+ send(:"#{name}_attacher").get
54
+ end
55
+
56
+ define_method :"#{name}_url" do |*args|
57
+ send(:"#{name}_attacher").url(*args)
58
+ end
59
+ end
60
+
61
+ # Creates an instance of the corresponding Attacher subclass.
62
+ def build_attacher(object, options)
63
+ shrine_class::Attacher.new(object, @name, @options.merge(options))
64
+ end
65
+
66
+ # Returns name of the attachment this module provides.
67
+ def attachment_name
68
+ @name
69
+ end
70
+
71
+ # Returns options that are to be passed to the Attacher.
72
+ def options
73
+ @options
74
+ end
75
+
76
+ # Returns class name with attachment name included.
77
+ #
78
+ # Shrine[:image].to_s #=> "#<Shrine::Attachment(image)>"
79
+ def to_s
80
+ "#<#{self.class.inspect}(#{attachment_name})>"
81
+ end
82
+
83
+ # Returns class name with attachment name included.
84
+ #
85
+ # Shrine[:image].inspect #=> "#<Shrine::Attachment(image)>"
86
+ def inspect
87
+ "#<#{self.class.inspect}(#{attachment_name})>"
88
+ end
89
+
90
+ # Returns the Shrine class that this attachment's class is namespaced
91
+ # under.
92
+ def shrine_class
93
+ self.class.shrine_class
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shrine
4
+ # Module in which all Shrine plugins should be stored. Also contains logic
5
+ # for registering and loading plugins.
6
+ module Plugins
7
+ @plugins = {}
8
+
9
+ # If the registered plugin already exists, use it. Otherwise, require it
10
+ # and return it. This raises a LoadError if such a plugin doesn't exist,
11
+ # or a Shrine::Error if it exists but it does not register itself
12
+ # correctly.
13
+ def self.load_plugin(name)
14
+ unless plugin = @plugins[name]
15
+ require "shrine/plugins/#{name}"
16
+ raise Error, "plugin #{name} did not register itself correctly in Shrine::Plugins" unless plugin = @plugins[name]
17
+ end
18
+ plugin
19
+ end
20
+
21
+ # Register the given plugin with Shrine, so that it can be loaded using
22
+ # `Shrine.plugin` with a symbol. Should be used by plugin files. Example:
23
+ #
24
+ # Shrine::Plugins.register_plugin(:plugin_name, PluginModule)
25
+ def self.register_plugin(name, mod)
26
+ @plugins[name] = mod
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ class Shrine
8
+ module Plugins
9
+ # The `urlsafe_serialization` plugin provides the ability to serialize and
10
+ # deserialize a `Shrine::UploadedFile` in a way that's suitable for
11
+ # including in a URL.
12
+ #
13
+ # plugin :urlsafe_serialization
14
+ #
15
+ # The plugin defines `urlsafe_dump` and `urlsafe_load` methods on
16
+ # `Shrine::UploadedFile`. The file is first serialized to JSON, then
17
+ # encoded with base64.
18
+ #
19
+ # serialized = uploaded_file.urlsafe_dump
20
+ # # or
21
+ # serialized = MyUploader::UploadedFile.urlsafe_dump(uploaded_file)
22
+ # serialized #=> "eyJpZCI6IjlhZGM0NmIzZjI..."
23
+ #
24
+ # # ...
25
+ #
26
+ # uploaded_file = MyUploader::UploadedFile.urlsafe_load(serialized)
27
+ # uploaded_file #=> #<MyUploader::UploadedFile>
28
+ #
29
+ # ## Metadata
30
+ #
31
+ # By default no metadata is included in the serialization:
32
+ #
33
+ # uploaded_file.metadata #=> { ... metadata ... }
34
+ #
35
+ # serialized = MyUploader::UploadedFile.urlsafe_dump(uploaded_file)
36
+ # uploaded_file = MyUploader::UploadedFile.urlsafe_load(serialized)
37
+ #
38
+ # uploaded_file.metadata #=> {}
39
+ #
40
+ # The `:metadata` option can be used to specify metadata you want to
41
+ # serialize:
42
+ #
43
+ # serialized = MyUploader::UploadedFile.urlsafe_dump(uploaded_file, metadata: %w[size mime_type])
44
+ # uploaded_file = MyUploader::UploadedFile.urlsafe_load(serialized)
45
+ #
46
+ # uploaded_file.metadata #=> { "size" => 4394, "mime_type" => "image/jpeg" }
47
+ #
48
+ # ## Signing
49
+ #
50
+ # By default the seralization is done with simple JSON + base64 encoding.
51
+ # If you want to ensure the serialized data hasn't been tampred with, you
52
+ # can have it signed with a secret key.
53
+ #
54
+ # plugin :urlsafe_serialization, secret_key: "my secret key"
55
+ #
56
+ # Now the `urlsafe_dump` will automatically sign serialized data with your
57
+ # secret key, and `urlsafe_load` will automatically verify it.
58
+ #
59
+ # serialized = MyUploader::UploadedFile.urlsafe_dump(uploaded_file)
60
+ # serialized #=> "<signature>--<json-base64-encoded-data>"
61
+ #
62
+ # uploaded_file = MyUploader::UploadedFile.urlsafe_load(serialized) # verifies the signature
63
+ # uploaded_file #=> #<MyUploader::UploadedFile>
64
+ #
65
+ # If the signature is missing or invalid,
66
+ # `Shrine::Plugins::UrlsafeSerialization::InvalidSignature` exception is
67
+ # raised.
68
+ module UrlsafeSerialization
69
+ class InvalidSignature < Error; end
70
+
71
+ def self.configure(uploader, opts = {})
72
+ uploader.opts[:urlsafe_serialization] ||= {}
73
+ uploader.opts[:urlsafe_serialization].merge!(opts)
74
+ end
75
+
76
+ module FileMethods
77
+ def urlsafe_dump(**options)
78
+ self.class.urlsafe_dump(self, **options)
79
+ end
80
+ end
81
+
82
+ module FileClassMethods
83
+ def urlsafe_dump(file, metadata: [])
84
+ data = file.data.dup
85
+ data["metadata"] = metadata
86
+ .map { |name| [name, file.metadata[name]] }
87
+ .to_h
88
+
89
+ urlsafe_serializer.dump(data)
90
+ end
91
+
92
+ def urlsafe_load(string)
93
+ data = urlsafe_serializer.load(string)
94
+
95
+ new(data)
96
+ end
97
+
98
+ def urlsafe_serializer
99
+ secret_key = shrine_class.opts[:urlsafe_serialization][:secret_key]
100
+
101
+ if secret_key
102
+ SecureSerializer.new(secret_key: secret_key)
103
+ else
104
+ Serializer.new
105
+ end
106
+ end
107
+ end
108
+
109
+ class Serializer
110
+ def dump(data)
111
+ base64_encode(json_encode(data))
112
+ end
113
+
114
+ def load(data)
115
+ json_decode(base64_decode(data))
116
+ end
117
+
118
+ private
119
+
120
+ def json_encode(data)
121
+ JSON.generate(data)
122
+ end
123
+
124
+ def base64_encode(data)
125
+ Base64.urlsafe_encode64(data, padding: false)
126
+ end
127
+
128
+ def base64_decode(data)
129
+ Base64.urlsafe_decode64(data)
130
+ end
131
+
132
+ def json_decode(data)
133
+ JSON.parse(data)
134
+ end
135
+ end
136
+
137
+ class SecureSerializer < Serializer
138
+ attr_reader :secret_key
139
+
140
+ def initialize(secret_key:)
141
+ @secret_key = secret_key
142
+ end
143
+
144
+ def dump(data)
145
+ hmac_encode(super)
146
+ end
147
+
148
+ def load(data)
149
+ super(hmac_decode(data))
150
+ end
151
+
152
+ def hmac_encode(data)
153
+ "#{generate_hmac(data)}--#{data}"
154
+ end
155
+
156
+ def hmac_decode(data)
157
+ data, hmac = data.split("--", 2).reverse
158
+ verify_hmac(hmac, data)
159
+ data
160
+ end
161
+
162
+ def verify_hmac(provided_hmac, data)
163
+ if provided_hmac.nil?
164
+ raise InvalidSignature, "signature is missing"
165
+ end
166
+
167
+ expected_hmac = generate_hmac(data)
168
+
169
+ if provided_hmac != expected_hmac
170
+ raise InvalidSignature, "provided signature doesn't match the expected"
171
+ end
172
+ end
173
+
174
+ def generate_hmac(data)
175
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret_key, data)
176
+ end
177
+ end
178
+ end
179
+
180
+ register_plugin(:_urlsafe_serialization, UrlsafeSerialization)
181
+ end
182
+ end