shrine 0.9.0 → 1.0.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.

@@ -157,7 +157,7 @@ class Shrine
157
157
  # model class. Example:
158
158
  #
159
159
  # class User
160
- # include Shrine[:avatar] # alias for `Shrine.attachment[:avatar]`
160
+ # include Shrine[:avatar] # alias for `Shrine.attachment(:avatar)`
161
161
  # end
162
162
  def attachment(name)
163
163
  self::Attachment.new(name)
@@ -184,30 +184,6 @@ class Shrine
184
184
  raise Error, "cannot convert #{object.inspect} to a #{self}::UploadedFile"
185
185
  end
186
186
  end
187
-
188
- # Delete a Shrine::UploadedFile.
189
- #
190
- # Shrine.delete(uploaded_file)
191
- def delete(uploaded_file, context = {})
192
- uploader_for(uploaded_file).delete(uploaded_file, context)
193
- end
194
-
195
- # Checks if the object is a valid IO by checking that it responds to
196
- # `#read`, `#eof?`, `#rewind`, `#size` and `#close`, otherwise raises
197
- # Shrine::InvalidFile.
198
- def io!(io)
199
- missing_methods = IO_METHODS.reject do |m, a|
200
- io.respond_to?(m) && [a.count, -1].include?(io.method(m).arity)
201
- end
202
-
203
- raise InvalidFile.new(io, missing_methods) if missing_methods.any?
204
- end
205
-
206
- # Instantiates the Shrine uploader instance for this file.
207
- def uploader_for(uploaded_file)
208
- uploaders = storages.keys.map { |key| new(key) }
209
- uploaders.find { |uploader| uploader.uploaded?(uploaded_file) }
210
- end
211
187
  end
212
188
 
213
189
  module InstanceMethods
@@ -272,7 +248,7 @@ class Shrine
272
248
  uploaded_file.storage_key == storage_key.to_s
273
249
  end
274
250
 
275
- # Called by `Shrine.delete`.
251
+ # Deletes the given uploaded file.
276
252
  def delete(uploaded_file, context = {})
277
253
  _delete(uploaded_file, context)
278
254
  uploaded_file
@@ -345,7 +321,7 @@ class Shrine
345
321
  )
346
322
  end
347
323
 
348
- # Removes the file. Called by `Shrine.delete`.
324
+ # Removes the file. Called by #delete.
349
325
  def _delete(uploaded_file, context)
350
326
  remove(uploaded_file, context)
351
327
  end
@@ -376,9 +352,15 @@ class Shrine
376
352
  process(io, context)
377
353
  end
378
354
 
379
- # Calls `Shrine#io!`.
355
+ # Checks if the object is a valid IO by checking that it responds to
356
+ # `#read`, `#eof?`, `#rewind`, `#size` and `#close`, otherwise raises
357
+ # Shrine::InvalidFile.
380
358
  def _enforce_io(io)
381
- self.class.io!(io)
359
+ missing_methods = IO_METHODS.reject do |m, a|
360
+ io.respond_to?(m) && [a.count, -1].include?(io.method(m).arity)
361
+ end
362
+
363
+ raise InvalidFile.new(io, missing_methods) if missing_methods.any?
382
364
  end
383
365
 
384
366
  # Generates a UID to use in location for uploaded files.
@@ -459,7 +441,7 @@ class Shrine
459
441
  # validation. Example:
460
442
  #
461
443
  # Shrine::Attacher.validate do
462
- # if get.size > 5.megabytes
444
+ # if get.size > 5*1024*1024
463
445
  # errors << "is too big (max is 5 MB)"
464
446
  # end
465
447
  # end
@@ -484,7 +466,7 @@ class Shrine
484
466
  # file (e.g. when it persisted after validation errors).
485
467
  # Otherwise it assumes that it's an IO object and caches it.
486
468
  def assign(value)
487
- if value.is_a?(String) || value.is_a?(Hash)
469
+ if value.is_a?(String)
488
470
  assign_cached(value) unless value == ""
489
471
  else
490
472
  uploaded_file = cache!(value, phase: :cache) if value
@@ -495,7 +477,7 @@ class Shrine
495
477
  # Assigns a Shrine::UploadedFile, runs validation and schedules the
496
478
  # old file for deletion.
497
479
  def set(uploaded_file)
498
- @old_attachment = get unless get == uploaded_file
480
+ @old = get unless get == uploaded_file
499
481
  _set(uploaded_file)
500
482
  validate
501
483
 
@@ -507,20 +489,30 @@ class Shrine
507
489
  uploaded_file(read) if read
508
490
  end
509
491
 
492
+ # Returns true if a new file has been attached.
493
+ def attached?
494
+ instance_variable_defined?("@old")
495
+ end
496
+
510
497
  # Plugins can override this if they want something to be done on save.
511
498
  def save
512
499
  end
513
500
 
501
+ # Deletes the old file and promotes the new one. Typically this should
502
+ # be called after saving.
503
+ def finalize
504
+ replace
505
+ _promote
506
+ end
507
+
514
508
  # Calls #promote if attached file is cached.
515
509
  def _promote
516
510
  promote(get) if promote?(get)
517
511
  end
518
512
 
519
- # Promotes a cached file to store, afterwards deleting the cached file.
520
- # It does the promoting only if the cached file matches the current
521
- # one. This check is done so that it can safely be used in background
522
- # jobs in case the user quickly changes their mind and replaces the
523
- # attachment before the old one was finished promoting.
513
+ # Promotes a cached file to store, taking into account to check whether
514
+ # the attachment has changed in the meanwhile. Afterwards the cached
515
+ # file is deleted.
524
516
  def promote(cached_file)
525
517
  stored_file = store!(cached_file, phase: :store)
526
518
  unless changed?(cached_file)
@@ -528,21 +520,20 @@ class Shrine
528
520
  else
529
521
  delete!(stored_file, phase: :stored)
530
522
  end
531
- delete!(cached_file, phase: :cached)
532
523
  end
533
524
 
534
- # Deletes the attachment that was replaced. Typically this should be
535
- # called after saving, to ensure that the file is deleted only after
536
- # the record has been successfuly saved.
525
+ # Deletes the attachment that was replaced, and is called after saving
526
+ # by ORM integrations. If also removes `@old` so that #save and #finalize
527
+ # don't get called for the current attachment anymore.
537
528
  def replace
538
- delete!(@old_attachment, phase: :replaced) if @old_attachment
539
- @old_attachment = nil
529
+ delete!(@old, phase: :replaced) if @old && !cache.uploaded?(@old)
530
+ remove_instance_variable("@old")
540
531
  end
541
532
 
542
533
  # Deletes the attachment. Typically this should be called after
543
534
  # destroying a record.
544
535
  def destroy
545
- delete!(get, phase: :destroyed) if get
536
+ delete!(get, phase: :destroyed) if get && !cache.uploaded?(get)
546
537
  end
547
538
 
548
539
  # Returns the URL to the attached file (internally calls `#url` on the
@@ -600,9 +591,9 @@ class Shrine
600
591
  store.upload(io, context.merge(phase: phase))
601
592
  end
602
593
 
603
- # Deletes the file (calls `Shrine.delete`).
594
+ # Deletes the file (calls `Shrine#delete`).
604
595
  def delete!(uploaded_file, phase:)
605
- shrine_class.delete(uploaded_file, context.merge(phase: phase))
596
+ store.delete(uploaded_file, context.merge(phase: phase))
606
597
  end
607
598
 
608
599
  # Delegates to `Shrine#default_url`.
@@ -792,6 +783,11 @@ class Shrine
792
783
  self.class.shrine_class
793
784
  end
794
785
 
786
+ # Show only the data hash in inspect output.
787
+ def inspect
788
+ "#{to_s.chomp(">")} @data=#{data.inspect}>"
789
+ end
790
+
795
791
  private
796
792
 
797
793
  def io
@@ -14,9 +14,16 @@ class Shrine
14
14
  # * `after_commit on: [:create, :update]` -- Promotes the attachment, deletes replaced ones.
15
15
  # * `after_commit on: [:destroy]` -- Deletes the attachment.
16
16
  #
17
- # Note that if your tests are wrapped in transactions, the `after_commit`
18
- # callbacks won't get called, so in order to test uploading you should first
19
- # disable these transactions for those tests.
17
+ # Note that ActiveRecord versions 3.x and 4.x have errors automatically
18
+ # silenced in hooks, which can make debugging more difficult, so it's
19
+ # recommended that you enable errors:
20
+ #
21
+ # # This is the default in ActiveRecord 5
22
+ # ActiveRecord::Base.raise_in_transactional_callbacks = true
23
+ #
24
+ # Also note that if your tests are wrapped in transactions, the
25
+ # `after_commit` callbacks won't get called, so in order to test uploading
26
+ # you should first disable these transactions for those tests.
20
27
  #
21
28
  # If you want to put some parts of this lifecycle into a background job, see
22
29
  # the background_helpers plugin.
@@ -42,12 +49,11 @@ class Shrine
42
49
  end
43
50
 
44
51
  before_save do
45
- #{@name}_attacher.save
52
+ #{@name}_attacher.save if #{@name}_attacher.attached?
46
53
  end
47
54
 
48
55
  after_commit on: [:create, :update] do
49
- #{@name}_attacher.replace
50
- #{@name}_attacher._promote
56
+ #{@name}_attacher.finalize if #{@name}_attacher.attached?
51
57
  end
52
58
 
53
59
  after_commit on: :destroy do
@@ -88,7 +88,7 @@ class Shrine
88
88
  end
89
89
 
90
90
  # If block is passed in, stores it to be called on deletion. Otherwise
91
- # resolves data into objects and calls Shrine.delete.
91
+ # resolves data into objects and calls `Shrine#delete`.
92
92
  def delete(data = nil, &block)
93
93
  if block
94
94
  shrine_class.opts[:background_delete] = block
@@ -98,11 +98,11 @@ class Shrine
98
98
  record.id = record_id
99
99
 
100
100
  name, phase = data["attachment"], data["phase"]
101
- shrine_class = record.send("#{name}_attacher").shrine_class
102
- uploaded_file = shrine_class.uploaded_file(data["uploaded_file"])
101
+ attacher = record.send("#{name}_attacher")
102
+ uploaded_file = attacher.uploaded_file(data["uploaded_file"])
103
103
  context = {name: name.to_sym, record: record, phase: phase.to_sym}
104
104
 
105
- shrine_class.delete(uploaded_file, context)
105
+ attacher.store.delete(uploaded_file, context)
106
106
  end
107
107
  end
108
108
  end
@@ -137,7 +137,7 @@ class Shrine
137
137
 
138
138
  instance_exec(data, &background_delete)
139
139
  else
140
- super
140
+ super(uploaded_file, phase: phase)
141
141
  end
142
142
  end
143
143
  end
@@ -29,7 +29,7 @@ class Shrine
29
29
  options[:store] = store
30
30
  end
31
31
 
32
- super
32
+ super(record, name, **options)
33
33
  end
34
34
  end
35
35
  end
@@ -0,0 +1,31 @@
1
+ class Shrine
2
+ module Plugins
3
+ # The default_url_options plugin allows you to specify URL options that
4
+ # will be applied by default for uploaded files of specified storages.
5
+ #
6
+ # plugin :default_url_options, store: {download: true}
7
+ #
8
+ # The default options are merged with options passed to `UploadedFile#url`,
9
+ # and the latter will always have precedence over default options.
10
+ module DefaultUrlOptions
11
+ def self.configure(uploader, **options)
12
+ uploader.opts[:default_url_options] = options
13
+ end
14
+
15
+ module FileMethods
16
+ def url(**options)
17
+ super(default_options.merge(options))
18
+ end
19
+
20
+ private
21
+
22
+ def default_options
23
+ options = shrine_class.opts[:default_url_options]
24
+ options[storage_key.to_sym] || {}
25
+ end
26
+ end
27
+ end
28
+
29
+ register_plugin(:default_url_options, DefaultUrlOptions)
30
+ end
31
+ end
@@ -64,6 +64,10 @@ class Shrine
64
64
  uploader.opts[:mime_type_analyzer] = analyzer
65
65
  end
66
66
 
67
+ # How many bytes we have to read to get the magic file header which
68
+ # contains the MIME type of the file.
69
+ MAGIC_NUMBER = 1024
70
+
67
71
  module InstanceMethods
68
72
  # If a Shrine::UploadedFile was given, it returns its MIME type, since
69
73
  # that value was already determined by this analyzer. Otherwise it calls
@@ -86,16 +90,22 @@ class Shrine
86
90
  # if it's a file, because even though the utility accepts standard
87
91
  # input, it would mean that we have to read the whole file in memory.
88
92
  def _extract_mime_type_with_file(io)
93
+ cmd = ["file", "--mime-type", "--brief"]
94
+
89
95
  if io.respond_to?(:path)
90
- mime_type, _ = Open3.capture2("file", "-b", "--mime-type", io.path)
91
- mime_type.strip unless mime_type.empty?
96
+ mime_type, _ = Open3.capture2(*cmd, io.path)
97
+ else
98
+ mime_type, _ = Open3.capture2(*cmd, "-", stdin_data: io.read(MAGIC_NUMBER), binmode: true)
99
+ io.rewind
92
100
  end
101
+
102
+ mime_type.strip unless mime_type.empty?
93
103
  end
94
104
 
95
105
  # Uses the ruby-filemagic gem to magically extract the MIME type.
96
106
  def _extract_mime_type_with_filemagic(io)
97
107
  filemagic = FileMagic.new(FileMagic::MAGIC_MIME_TYPE)
98
- data = io.read(1024); io.rewind
108
+ data = io.read(MAGIC_NUMBER); io.rewind
99
109
  filemagic.buffer(data)
100
110
  end
101
111
 
@@ -1,7 +1,6 @@
1
1
  require "roda"
2
2
 
3
3
  require "json"
4
- require "forwardable"
5
4
  require "securerandom"
6
5
 
7
6
  class Shrine
@@ -9,7 +8,7 @@ class Shrine
9
8
  # The direct_upload plugin provides a Rack endpoint (implemented in [Roda])
10
9
  # which you can use to implement AJAX uploads.
11
10
  #
12
- # plugin :direct_upload, max_size: 20*1024*1024
11
+ # plugin :direct_upload
13
12
  #
14
13
  # This is how you could mount the endpoint in a Rails application:
15
14
  #
@@ -20,35 +19,23 @@ class Shrine
20
19
  #
21
20
  # Note that you should mount a separate endpoint for each uploader that you
22
21
  # want to use it with. This now gives your Ruby application a
23
- # `POST /attachments/images/:storage/:name` route, which accepts a "file"
24
- # query parameter:
22
+ # `POST /attachments/images/:storage/:name` route, which accepts a `file`
23
+ # query parameter, and returns the uploaded file in JSON format:
25
24
  #
26
- # $ curl -F "file=@/path/to/avatar.jpg" localhost:3000/attachments/images/cache/avatar
27
- # # {"id":"43kewit94.jpg","storage":"cache","metadata":{...}}
28
- #
29
- # The endpoint returns all responses in JSON format. There are many great
30
- # JavaScript libraries for AJAX file uploads, so for example if we have
31
- # this form:
32
- #
33
- # <%= form_for @user do |f| %>
34
- # <%= f.hidden_field :avatar, value: @user.avatar_data %>
35
- # <%= f.file_field :avatar %>
36
- # <% end %>
37
- #
38
- # this is how we could hook up [jQuery-File-Upload] to our direct upload
39
- # endpoint:
40
- #
41
- # $('[type="file"]').fileupload({
42
- # url: '/attachments/images/cache/avatar',
43
- # paramName: 'file',
44
- # done: function(e, data) { $(this).prev().value(data.result) }
45
- # });
25
+ # # POST /attachments/images/cache/avatar
26
+ # {
27
+ # "id": "43kewit94.jpg",
28
+ # "storage": "cache",
29
+ # "metadata": {
30
+ # "size": 384393,
31
+ # "filename": "nature.jpg",
32
+ # "mime_type": "image/jpeg"
33
+ # }
34
+ # }
46
35
  #
47
- # Now whenever a file gets chosen, the upload will automatically start in
48
- # the background. It's typically good to show a progress bar to the user,
49
- # which jQuery-File-Upload [supports]. After the upload has finished, the
50
- # uploaded file JSON is written to the hidden field, and will be sent on
51
- # form submit.
36
+ # There are many great JavaScript libraries for AJAX file uploads which can
37
+ # be hooked up to this endpoint, [jQuery-File-Upload] being the most
38
+ # popular one.
52
39
  #
53
40
  # ## Presigned
54
41
  #
@@ -78,33 +65,29 @@ class Shrine
78
65
  # The `url` is where the file needs to be uploaded to, and `fields` is
79
66
  # additional data that needs to be send on the upload. The `fields.key`
80
67
  # attribute is the location where the file will be uploaded to, it is
81
- # generated randomly. `GET /:storage/presign` accepts additional parameters:
82
- #
83
- # :extension
84
- # : The extension of the file being uploaded (e.g ".jpg").
68
+ # generated randomly, but you can add an extension to it:
85
69
  #
86
- # :content_type
87
- # : Sets the Content-Type header for the uploaded file on S3.
88
- #
89
- # Example:
90
- #
91
- # GET /cache/presign?content_type=image/jpeg
92
70
  # GET /cache/presign?extension=.png
93
71
  #
94
- # ## Constraints
72
+ # If you want additional options to be passed to Storage::S3#presign, you
73
+ # can pass a block to `:presign` and return additional options:
95
74
  #
96
- # Note that the direct upload doesn't run validations, they are only run
97
- # when attached to the record. If you want to limit the MIME type of files,
98
- # you could add an ["accept" attribute] to your file field. You could also
99
- # add client side validations for the maximum file size.
75
+ # plugin :direct_upload, presign: ->(request) do
76
+ # {
77
+ # content_length_range: 0..(5*1024*1024), # limit the filesize to 5 MB
78
+ # success_action_redirect: "http://example.com/webhook",
79
+ # }
80
+ # end
100
81
  #
101
- # It's encouraged that you set the `:max_size` option for the endpoint.
102
- # Once set, when a file that is too big is uploaded, the endpoint will
103
- # automatically delete the file and return a 413 response. However, if for
104
- # whatever reason you don't want to impose a limit on filesize, you can set
105
- # the option to nil:
82
+ # The yielded object is an instance of [`Roda::RodaRequest`] (a subclass of
83
+ # `Rack::Request`), which allows you to pass different options depending on
84
+ # the request. For example, you could accept a `content_type` query
85
+ # parameter and add it to options:
106
86
  #
107
- # plugin :direct_upload, max_size: nil
87
+ # plugin :direct_upload, presign: ->(request) do
88
+ # {content_type: request.params["content_type"]} if request.params["content_type"]
89
+ # end
90
+ # # Now you can do `GET /cache/presign?content_type=image/jpeg`
108
91
  #
109
92
  # ## Allowed storages
110
93
  #
@@ -130,10 +113,14 @@ class Shrine
130
113
  # [jQuery-File-Upload]: https://github.com/blueimp/jQuery-File-Upload
131
114
  # [supports]: https://github.com/blueimp/jQuery-File-Upload/wiki/Options#progress
132
115
  # ["accept" attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-accept
116
+ # [`Roda::RodaRequest`]: http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Base/RequestMethods.html
133
117
  module DirectUpload
134
- def self.configure(uploader, allowed_storages: [:cache], max_size:, presign: nil)
118
+ def self.load_dependencies(uploader, *)
119
+ uploader.plugin :rack_file
120
+ end
121
+
122
+ def self.configure(uploader, allowed_storages: [:cache], presign: nil, **)
135
123
  uploader.opts[:direct_upload_allowed_storages] = allowed_storages
136
- uploader.opts[:direct_upload_max_size] = max_size
137
124
  uploader.opts[:direct_upload_presign] = presign
138
125
  end
139
126
 
@@ -165,23 +152,23 @@ class Shrine
165
152
  allow_storage!(storage_key)
166
153
  @uploader = shrine_class.new(storage_key.to_sym)
167
154
 
168
- r.post ":name" do |name|
169
- file = get_file
170
- context = {name: name, phase: :cache}
155
+ unless presign
156
+ r.post ":name" do |name|
157
+ file = get_file
158
+ context = {name: name, phase: :cache}
171
159
 
172
- json @uploader.upload(file, context)
173
- end unless presign?
160
+ json @uploader.upload(file, context)
161
+ end
162
+ else
163
+ r.get "presign" do
164
+ location = SecureRandom.hex(30) + r.params["extension"].to_s
165
+ options = presign.call(r) if presign.respond_to?(:call)
174
166
 
175
- r.get "presign" do
176
- location = SecureRandom.hex(30).to_s + r.params["extension"].to_s
177
- options = {}
178
- options[:content_length_range] = 0..max_size if max_size
179
- options[:content_type] = r.params["content_type"] if r.params["content_type"]
167
+ signature = @uploader.storage.presign(location, options || {})
180
168
 
181
- signature = @uploader.storage.presign(location, options)
182
-
183
- json Hash[url: signature.url, fields: signature.fields]
184
- end if presign?
169
+ json Hash[url: signature.url, fields: signature.fields]
170
+ end
171
+ end
185
172
  end
186
173
  end
187
174
 
@@ -201,18 +188,8 @@ class Shrine
201
188
  def get_file
202
189
  file = require_param!("file")
203
190
  error! 400, "The \"file\" query parameter is not a file." if !(file.is_a?(Hash) && file.key?(:tempfile))
204
- check_filesize!(file[:tempfile]) if max_size
205
-
206
- RackFile.new(file)
207
- end
208
191
 
209
- # If the file is too big, deletes the file and halts the request.
210
- def check_filesize!(file)
211
- if file.size > max_size
212
- file.delete
213
- megabytes = max_size.to_f / 1024 / 1024
214
- error! 413, "The file is too big (maximum size is #{megabytes} MB)."
215
- end
192
+ RackFile::UploadedFile.new(file)
216
193
  end
217
194
 
218
195
  # Loudly requires the param.
@@ -235,38 +212,10 @@ class Shrine
235
212
  shrine_class.opts[:direct_upload_allowed_storages]
236
213
  end
237
214
 
238
- def max_size
239
- shrine_class.opts[:direct_upload_max_size]
240
- end
241
-
242
- def presign?
215
+ def presign
243
216
  shrine_class.opts[:direct_upload_presign]
244
217
  end
245
218
  end
246
-
247
- # This is used to wrap the Rack hash into an IO-like object which Shrine
248
- # can upload.
249
- class RackFile
250
- attr_reader :original_filename, :content_type
251
- attr_accessor :tempfile
252
-
253
- def initialize(tempfile:, filename: nil, type: nil, **)
254
- @tempfile = tempfile
255
- @original_filename = filename
256
- @content_type = type
257
- end
258
-
259
- def path
260
- @tempfile.path
261
- end
262
-
263
- def to_io
264
- @tempfile
265
- end
266
-
267
- extend Forwardable
268
- delegate Shrine::IO_METHODS.keys => :@tempfile
269
- end
270
219
  end
271
220
 
272
221
  register_plugin(:direct_upload, DirectUpload)