shrine 3.4.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/README.md +11 -11
  4. data/doc/advantages.md +1 -1
  5. data/doc/attacher.md +1 -1
  6. data/doc/carrierwave.md +2 -2
  7. data/doc/changing_derivatives.md +2 -1
  8. data/doc/changing_location.md +19 -6
  9. data/doc/design.md +4 -4
  10. data/doc/external/articles.md +16 -16
  11. data/doc/external/extensions.md +1 -1
  12. data/doc/getting_started.md +73 -30
  13. data/doc/multiple_files.md +57 -22
  14. data/doc/plugins/activerecord.md +91 -0
  15. data/doc/plugins/backgrounding.md +30 -4
  16. data/doc/plugins/data_uri.md +15 -0
  17. data/doc/plugins/derivation_endpoint.md +39 -1
  18. data/doc/plugins/derivatives.md +13 -2
  19. data/doc/plugins/download_endpoint.md +16 -4
  20. data/doc/plugins/dynamic_storage.md +15 -0
  21. data/doc/plugins/fallback_storage.md +90 -0
  22. data/doc/plugins/keep_files.md +6 -4
  23. data/doc/plugins/rack_response.md +9 -0
  24. data/doc/plugins/refresh_metadata.md +20 -0
  25. data/doc/plugins/sequel.md +57 -0
  26. data/doc/plugins/signature.md +8 -6
  27. data/doc/plugins/tempfile.md +14 -6
  28. data/doc/plugins/upload_options.md +12 -0
  29. data/doc/plugins/url_options.md +12 -0
  30. data/doc/plugins/validation_helpers.md +1 -1
  31. data/doc/processing.md +5 -3
  32. data/doc/refile.md +3 -3
  33. data/doc/release_notes/2.1.0.md +1 -1
  34. data/doc/release_notes/3.10.0.md +30 -0
  35. data/doc/release_notes/3.5.0.md +63 -0
  36. data/doc/release_notes/3.6.0.md +23 -0
  37. data/doc/release_notes/3.7.0.md +75 -0
  38. data/doc/release_notes/3.7.1.md +31 -0
  39. data/doc/release_notes/3.8.0.md +37 -0
  40. data/doc/release_notes/3.9.0.md +42 -0
  41. data/doc/retrieving_uploads.md +1 -1
  42. data/doc/storage/s3.md +10 -0
  43. data/doc/testing.md +45 -17
  44. data/doc/upgrading_to_3.md +3 -5
  45. data/lib/shrine/attacher.rb +41 -29
  46. data/lib/shrine/attachment.rb +2 -2
  47. data/lib/shrine/plugins/_persistence.rb +1 -1
  48. data/lib/shrine/plugins/_urlsafe_serialization.rb +4 -4
  49. data/lib/shrine/plugins/activerecord.rb +25 -4
  50. data/lib/shrine/plugins/add_metadata.rb +2 -4
  51. data/lib/shrine/plugins/atomic_helpers.rb +7 -7
  52. data/lib/shrine/plugins/backgrounding.rb +10 -10
  53. data/lib/shrine/plugins/column.rb +7 -5
  54. data/lib/shrine/plugins/data_uri.rb +28 -3
  55. data/lib/shrine/plugins/default_url.rb +4 -4
  56. data/lib/shrine/plugins/delete_raw.rb +2 -2
  57. data/lib/shrine/plugins/derivation_endpoint.rb +65 -59
  58. data/lib/shrine/plugins/derivatives.rb +35 -21
  59. data/lib/shrine/plugins/download_endpoint.rb +72 -11
  60. data/lib/shrine/plugins/entity.rb +15 -9
  61. data/lib/shrine/plugins/fallback_storage.rb +51 -0
  62. data/lib/shrine/plugins/infer_extension.rb +5 -1
  63. data/lib/shrine/plugins/instrumentation.rb +13 -9
  64. data/lib/shrine/plugins/metadata_attributes.rb +1 -1
  65. data/lib/shrine/plugins/mirroring.rb +10 -10
  66. data/lib/shrine/plugins/model.rb +12 -10
  67. data/lib/shrine/plugins/presign_endpoint.rb +13 -10
  68. data/lib/shrine/plugins/pretty_location.rb +2 -2
  69. data/lib/shrine/plugins/processing.rb +3 -3
  70. data/lib/shrine/plugins/rack_file.rb +2 -2
  71. data/lib/shrine/plugins/rack_response.rb +12 -6
  72. data/lib/shrine/plugins/refresh_metadata.rb +6 -6
  73. data/lib/shrine/plugins/remote_url.rb +4 -4
  74. data/lib/shrine/plugins/remove_attachment.rb +2 -0
  75. data/lib/shrine/plugins/remove_invalid.rb +4 -0
  76. data/lib/shrine/plugins/restore_cached_data.rb +3 -3
  77. data/lib/shrine/plugins/sequel.rb +15 -1
  78. data/lib/shrine/plugins/signature.rb +2 -2
  79. data/lib/shrine/plugins/store_dimensions.rb +2 -2
  80. data/lib/shrine/plugins/tempfile.rb +0 -2
  81. data/lib/shrine/plugins/upload_endpoint.rb +7 -5
  82. data/lib/shrine/plugins/upload_options.rb +18 -4
  83. data/lib/shrine/plugins/url_options.rb +17 -3
  84. data/lib/shrine/plugins/validation.rb +8 -8
  85. data/lib/shrine/plugins/validation_helpers.rb +2 -2
  86. data/lib/shrine/plugins/versions.rb +10 -10
  87. data/lib/shrine/plugins.rb +6 -14
  88. data/lib/shrine/storage/file_system.rb +15 -19
  89. data/lib/shrine/storage/linter.rb +8 -8
  90. data/lib/shrine/storage/memory.rb +1 -3
  91. data/lib/shrine/storage/s3.rb +60 -40
  92. data/lib/shrine/uploaded_file.rb +23 -19
  93. data/lib/shrine/version.rb +1 -1
  94. data/lib/shrine.rb +24 -20
  95. data/shrine.gemspec +10 -8
  96. metadata +52 -26
@@ -15,8 +15,8 @@ class Shrine
15
15
  private
16
16
 
17
17
  # Deletes the file that was uploaded, unless it's an UploadedFile.
18
- def _upload(io, delete: delete_raw?, **options)
19
- super(io, delete: delete, **options)
18
+ def _upload(io, delete: delete_raw?, **)
19
+ super(io, delete:, **)
20
20
  end
21
21
 
22
22
  def delete_raw?
@@ -28,8 +28,8 @@ class Shrine
28
28
  uploader.opts[:derivation_endpoint] ||= { options: {}, derivations: {} }
29
29
  uploader.opts[:derivation_endpoint][:options].merge!(opts)
30
30
 
31
- unless uploader.opts[:derivation_endpoint][:options][:secret_key]
32
- fail Error, "must provide :secret_key option to derivation_endpoint plugin"
31
+ if !uploader.opts[:derivation_endpoint][:options][:secret_key] && !uploader.opts[:derivation_endpoint][:options][:signer]
32
+ fail Error, "must provide :secret_key option to derivation_endpoint plugin when no custom signer is set"
33
33
  end
34
34
 
35
35
  # instrumentation plugin integration
@@ -48,7 +48,7 @@ class Shrine
48
48
  # It uses a trick where it removes the derivation path prefix from the
49
49
  # path info before calling the Rack app, which is what web framework
50
50
  # routers do before they're calling a mounted Rack app.
51
- def derivation_response(env, **options)
51
+ def derivation_response(env, **)
52
52
  script_name = env["SCRIPT_NAME"]
53
53
  path_info = env["PATH_INFO"]
54
54
 
@@ -61,7 +61,7 @@ class Shrine
61
61
  env["SCRIPT_NAME"] += match.to_s
62
62
  env["PATH_INFO"] = match.post_match
63
63
 
64
- derivation_endpoint(**options).call(env)
64
+ derivation_endpoint(**).call(env)
65
65
  ensure
66
66
  env["SCRIPT_NAME"] = script_name
67
67
  env["PATH_INFO"] = path_info
@@ -87,15 +87,15 @@ class Shrine
87
87
  # Generates a URL to a derivation with the receiver as the source file.
88
88
  # Any arguments provided will be included in the URL and passed to the
89
89
  # derivation block. Accepts additional URL options.
90
- def derivation_url(name, *args, **options)
91
- derivation(name, *args).url(**options)
90
+ def derivation_url(name, *, **)
91
+ derivation(name, *).url(**)
92
92
  end
93
93
 
94
94
  # Calls the specified derivation with the receiver as the source file,
95
95
  # returning a Rack response triple. The derivation endpoint ultimately
96
96
  # calls this method.
97
- def derivation_response(name, *args, env:, **options)
98
- derivation(name, *args, **options).response(env)
97
+ def derivation_response(name, *, env:, **)
98
+ derivation(name, *, **).response(env)
99
99
  end
100
100
 
101
101
  # Returns a Shrine::Derivation object created from the provided
@@ -129,14 +129,14 @@ class Shrine
129
129
  end
130
130
 
131
131
  # Returns an URL to the derivation.
132
- def url(**options)
132
+ def url(**)
133
133
  Derivation::Url.new(self).call(
134
134
  host: option(:host),
135
135
  prefix: option(:prefix),
136
136
  expires_in: option(:expires_in),
137
137
  version: option(:version),
138
138
  metadata: option(:metadata),
139
- **options,
139
+ **,
140
140
  )
141
141
  end
142
142
 
@@ -158,8 +158,8 @@ class Shrine
158
158
 
159
159
  # Uploads the derivation result to a dedicated destination on the specified
160
160
  # Shrine storage.
161
- def upload(file = nil, **options)
162
- Derivation::Upload.new(self).call(file, **options)
161
+ def upload(file = nil, **)
162
+ Derivation::Upload.new(self).call(file, **)
163
163
  end
164
164
 
165
165
  # Returns a Shrine::UploadedFile object pointing to the uploaded derivative
@@ -184,7 +184,7 @@ class Shrine
184
184
  end
185
185
 
186
186
  def self.option(name, default: nil, result: nil)
187
- options[name] = { default: default, result: result }
187
+ options[name] = { default:, result: }
188
188
  end
189
189
 
190
190
  option :cache_control, default: -> { default_cache_control }
@@ -197,6 +197,7 @@ class Shrine
197
197
  option :metadata, default: -> { [] }
198
198
  option :prefix
199
199
  option :secret_key
200
+ option :signer
200
201
  option :type
201
202
  option :upload, default: -> { false }
202
203
  option :upload_location, default: -> { default_upload_location }, result: -> (o) { upload_location(o) }
@@ -216,7 +217,7 @@ class Shrine
216
217
  option_definition = self.class.options.fetch(name)
217
218
 
218
219
  value = options.fetch(name) { shrine_class.derivation_options[name] }
219
- value = instance_exec(&value) if value.is_a?(Proc)
220
+ value = instance_exec(&value) if value.is_a?(Proc) && value.arity == 0
220
221
 
221
222
  if value.nil?
222
223
  default = option_definition[:default]
@@ -300,20 +301,37 @@ class Shrine
300
301
  end
301
302
 
302
303
  class Derivation::Url < Derivation::Command
303
- delegate :name, :args, :source, :secret_key
304
+ delegate :name, :args, :source, :secret_key, :signer
304
305
 
305
- def call(host: nil, prefix: nil, **options)
306
- [host, *prefix, identifier(**options)].join("/")
306
+ def call(host: nil, prefix: nil, metadata: [], format: nil, **)
307
+ base_url = [host, *prefix].join("/")
308
+ path = path_identifier(metadata:, format:)
309
+
310
+ if signer
311
+ url = [base_url, path].join("/")
312
+ signer.call(url, **)
313
+ else
314
+ signed_part = signed_url("#{path}?#{query(**)}")
315
+ [base_url, signed_part].join("/")
316
+ end
307
317
  end
308
318
 
309
319
  private
310
320
 
311
- def identifier(expires_in: nil,
312
- version: nil,
313
- type: nil,
314
- filename: nil,
315
- disposition: nil,
316
- metadata: [])
321
+ def path_identifier(metadata: [], format: nil)
322
+ path = [
323
+ name,
324
+ *args,
325
+ source.urlsafe_dump(metadata:)
326
+ ].map{|component| Rack::Utils.escape_path(component.to_s)}.join('/')
327
+ format ? "#{path}.#{format}" : path
328
+ end
329
+
330
+ def query(expires_in: nil,
331
+ type: nil,
332
+ filename: nil,
333
+ disposition: nil,
334
+ version: nil)
317
335
 
318
336
  params = {}
319
337
  params[:expires_at] = (Time.now + expires_in).to_i if expires_in
@@ -322,23 +340,7 @@ class Shrine
322
340
  params[:filename] = filename if filename
323
341
  params[:disposition] = disposition if disposition
324
342
 
325
- # serializes the source uploaded file into an URL-safe format
326
- source_component = source.urlsafe_dump(metadata: metadata)
327
-
328
- # generate plain URL
329
- url = plain_url(name, *args, source_component, params)
330
-
331
- # generate signed URL
332
- signed_url(url)
333
- end
334
-
335
- def plain_url(*components, params)
336
- # When using Rack < 2, Rack::Utils#escape_path will escape '/'.
337
- # Escape each component and then join them together.
338
- path = components.map{|component| Rack::Utils.escape_path(component.to_s)}.join('/')
339
- query = Rack::Utils.build_query(params)
340
-
341
- "#{path}?#{query}"
343
+ Rack::Utils.build_query(params)
342
344
  end
343
345
 
344
346
  def signed_url(url)
@@ -364,7 +366,9 @@ class Shrine
364
366
  handle_request(request)
365
367
  end
366
368
 
367
- headers["Content-Length"] ||= body.map(&:bytesize).inject(0, :+).to_s
369
+ headers = Rack::Headers[headers] if Rack.release >= "3"
370
+ headers["Content-Length"] ||= body.respond_to?(:bytesize) ? body.bytesize.to_s :
371
+ body.map(&:bytesize).inject(0, :+).to_s
368
372
 
369
373
  [status, headers, body]
370
374
  end
@@ -379,10 +383,11 @@ class Shrine
379
383
  # Returns "404 Not Found" if derivation block is not defined, or if source
380
384
  # file was not found on the storage.
381
385
  def handle_request(request)
382
- verify_signature!(request)
386
+ verify_signature!(request) if secret_key
383
387
  check_expiry!(request)
384
388
 
385
389
  name, *args, serialized_file = request.path_info.split("/")[1..-1]
390
+ serialized_file = serialized_file.sub(/\.\w+$/, "")
386
391
 
387
392
  name = name.to_sym
388
393
  uploaded_file = shrine_class::UploadedFile.urlsafe_load(serialized_file)
@@ -480,19 +485,18 @@ class Shrine
480
485
  # `Content-Type` and `Content-Disposition` response headers from derivation
481
486
  # options and file extension of the derivation result.
482
487
  def file_response(file, env)
483
- response = rack_file_response(file.path, env)
484
-
485
- status = response[0]
488
+ status, headers, body = rack_file_response(file.path, env)
486
489
 
490
+ headers = Rack::Headers[headers] if Rack.release >= "3"
487
491
  headers = {
488
- "Content-Type" => type || response[1]["Content-Type"],
489
- "Content-Length" => response[1]["Content-Length"],
492
+ "Content-Type" => type || headers["Content-Type"],
493
+ "Content-Length" => headers["Content-Length"],
490
494
  "Content-Disposition" => content_disposition(file),
491
- "Content-Range" => response[1]["Content-Range"],
495
+ "Content-Range" => headers["Content-Range"],
492
496
  "Accept-Ranges" => "bytes",
493
497
  }.compact
494
498
 
495
- body = Rack::BodyProxy.new(response[2]) { File.delete(file.path) }
499
+ body = Rack::BodyProxy.new(body) { File.delete(file.path) }
496
500
 
497
501
  file.close
498
502
 
@@ -513,8 +517,10 @@ class Shrine
513
517
 
514
518
  if upload_redirect
515
519
  redirect_url = uploaded_file.url(**upload_redirect_url_options)
520
+ headers = { "Location" => redirect_url }
521
+ headers = Rack::Headers[headers] if Rack.release >= "3"
516
522
 
517
- [302, { "Location" => redirect_url }, []]
523
+ [302, headers, []]
518
524
  else
519
525
  if derivative && File.exist?(derivative.path)
520
526
  file_response(derivative, env)
@@ -554,7 +560,7 @@ class Shrine
554
560
  filename = self.filename
555
561
  filename += File.extname(file.path) if File.extname(filename).empty?
556
562
 
557
- ContentDisposition.format(disposition: disposition, filename: filename)
563
+ ContentDisposition.format(disposition:, filename:)
558
564
  end
559
565
  end
560
566
 
@@ -593,9 +599,9 @@ class Shrine
593
599
  end
594
600
 
595
601
  # Calls the derivation block.
596
- def derive(*args)
602
+ def derive(*)
597
603
  instrument_derivation do
598
- derivation.instance_exec(*args, &derivation_block)
604
+ derivation.instance_exec(*, &derivation_block)
599
605
  end
600
606
  end
601
607
 
@@ -603,7 +609,7 @@ class Shrine
603
609
  def instrument_derivation(&block)
604
610
  return yield unless shrine_class.respond_to?(:instrument)
605
611
 
606
- shrine_class.instrument(:derivation, { derivation: derivation }, &block)
612
+ shrine_class.instrument(:derivation, { derivation: }, &block)
607
613
  end
608
614
 
609
615
  # Massages the derivation result, ensuring it's opened in binary mode,
@@ -642,22 +648,22 @@ class Shrine
642
648
  # Uploads the derivation result to the dedicated location on the storage.
643
649
  # If a file object is given, uploads that to the storage, otherwise calls
644
650
  # the derivation block and uploads the result.
645
- def call(derivative = nil, **options)
651
+ def call(derivative = nil, **)
646
652
  if derivative
647
- upload(derivative, **options)
653
+ upload(derivative, **)
648
654
  else
649
- upload(derivation.generate, delete: true, **options)
655
+ upload(derivation.generate, delete: true, **)
650
656
  end
651
657
  end
652
658
 
653
659
  private
654
660
 
655
- def upload(io, **options)
661
+ def upload(io, **)
656
662
  shrine_class.upload io, upload_storage,
657
663
  location: upload_location,
658
664
  upload_options: upload_options,
659
665
  action: :derivation,
660
- **options
666
+ **
661
667
  end
662
668
  end
663
669
 
@@ -12,18 +12,18 @@ class Shrine
12
12
  }.inspect}"
13
13
  end
14
14
 
15
- def self.load_dependencies(uploader, versions_compatibility: false, **)
15
+ def self.load_dependencies(uploader, **)
16
16
  uploader.plugin :default_url
17
-
18
- AttacherMethods.prepend(VersionsCompatibility) if versions_compatibility
19
17
  end
20
18
 
21
- def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, **opts)
22
- uploader.opts[:derivatives] ||= { processors: {}, processor_settings: {}, storage: proc { store_key } }
19
+ def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, versions_compatibility: false, **opts)
20
+ uploader.opts[:derivatives] ||= { processors: {}, processor_settings: {}, storage: proc { store_key }, mutex: true }
23
21
  uploader.opts[:derivatives].merge!(opts)
24
22
 
25
23
  # instrumentation plugin integration
26
24
  uploader.subscribe(:derivatives, &log_subscriber) if uploader.respond_to?(:subscribe)
25
+
26
+ uploader::Attacher.include(VersionsCompatibility) if versions_compatibility
27
27
  end
28
28
 
29
29
  module AttachmentMethods
@@ -109,7 +109,7 @@ class Shrine
109
109
  super(**options)
110
110
 
111
111
  @derivatives = derivatives
112
- @derivatives_mutex = Mutex.new
112
+ @derivatives_mutex = Mutex.new if shrine_class.derivatives_options[:mutex]
113
113
  end
114
114
 
115
115
  # Convenience method for accessing derivatives.
@@ -139,7 +139,7 @@ class Shrine
139
139
  # Allows generating a URL to the derivative by passing the derivative
140
140
  # name.
141
141
  #
142
- # attacher.add_derivatives(thumb: thumb)
142
+ # attacher.add_derivatives({ thumb: thumb })
143
143
  # attacher.url(:thumb) #=> "https://example.org/thumb.jpg"
144
144
  def url(*path, **options)
145
145
  return super if path.empty?
@@ -161,7 +161,7 @@ class Shrine
161
161
  # attacher.stored?(attacher.derivatives[:thumb]) #=> true
162
162
  def promote(**options)
163
163
  super
164
- promote_derivatives
164
+ promote_derivatives(**options)
165
165
  create_derivatives if create_derivatives_on_promote?
166
166
  end
167
167
 
@@ -180,7 +180,7 @@ class Shrine
180
180
 
181
181
  # In addition to deleting the main file it also deletes any derivatives.
182
182
  #
183
- # attacher.add_derivatives(thumb: thumb)
183
+ # attacher.add_derivatives({ thumb: thumb })
184
184
  # attacher.derivatives[:thumb].exists? #=> true
185
185
  # attacher.destroy
186
186
  # attacher.derivatives[:thumb].exists? #=> false
@@ -208,7 +208,7 @@ class Shrine
208
208
  # # {
209
209
  # # thumb: #<Shrine::UploadedFile>,
210
210
  # # }
211
- # attacher.add_derivatives(cropped: cropped)
211
+ # attacher.add_derivatives({ cropped: cropped })
212
212
  # attacher.derivatives #=>
213
213
  # # {
214
214
  # # thumb: #<Shrine::UploadedFile>,
@@ -239,7 +239,7 @@ class Shrine
239
239
 
240
240
  # Uploads given hash of files.
241
241
  #
242
- # hash = attacher.upload_derivatives(thumb: thumb)
242
+ # hash = attacher.upload_derivatives({ thumb: thumb })
243
243
  # hash[:thumb] #=> #<Shrine::UploadedFile>
244
244
  def upload_derivatives(files, **options)
245
245
  map_derivative(files) do |path, file|
@@ -298,10 +298,10 @@ class Shrine
298
298
  # Deep merges given uploaded derivatives with current derivatives.
299
299
  #
300
300
  # attacher.derivatives #=> { one: #<Shrine::UploadedFile> }
301
- # attacher.merge_derivatives(two: uploaded_file)
301
+ # attacher.merge_derivatives({ two: uploaded_file })
302
302
  # attacher.derivatives #=> { one: #<Shrine::UploadedFile>, two: #<Shrine::UploadedFile> }
303
303
  def merge_derivatives(new_derivatives)
304
- @derivatives_mutex.synchronize do
304
+ derivatives_synchronize do
305
305
  merged_derivatives = deep_merge_derivatives(derivatives, new_derivatives)
306
306
  set_derivatives(merged_derivatives)
307
307
  end
@@ -379,7 +379,7 @@ class Shrine
379
379
 
380
380
  # Deletes given hash of uploaded files.
381
381
  #
382
- # attacher.delete_derivatives(thumb: uploaded_file)
382
+ # attacher.delete_derivatives({ thumb: uploaded_file })
383
383
  # uploaded_file.exists? #=> false
384
384
  def delete_derivatives(derivatives = self.derivatives)
385
385
  map_derivative(derivatives) { |_, derivative| derivative.delete }
@@ -387,7 +387,7 @@ class Shrine
387
387
 
388
388
  # Sets the given hash of uploaded files as derivatives.
389
389
  #
390
- # attacher.set_derivatives(thumb: uploaded_file)
390
+ # attacher.set_derivatives({ thumb: uploaded_file })
391
391
  # attacher.derivatives #=> { thumb: #<Shrine::UploadedFile> }
392
392
  def set_derivatives(derivatives)
393
393
  self.derivatives = derivatives
@@ -398,7 +398,7 @@ class Shrine
398
398
  # Adds derivative data into the hash.
399
399
  #
400
400
  # attacher.attach(io)
401
- # attacher.add_derivatives(thumb: thumb)
401
+ # attacher.add_derivatives({ thumb: thumb })
402
402
  # attacher.data
403
403
  # #=>
404
404
  # # {
@@ -431,12 +431,12 @@ class Shrine
431
431
  # attacher.load_data({
432
432
  # "id" => "...",
433
433
  # "storage" => "store",
434
- # "metadata" => { ... },
434
+ # "metadata" => {},
435
435
  # "derivatives" => {
436
436
  # "thumb" => {
437
437
  # "id" => "...",
438
438
  # "storage" => "store",
439
- # "metadata" => { ... },
439
+ # "metadata" => {},
440
440
  # }
441
441
  # }
442
442
  # })
@@ -459,15 +459,19 @@ class Shrine
459
459
  # attacher.derivatives #=> { thumb: #<Shrine::UploadedFile> }
460
460
  # attacher.change(file)
461
461
  # attacher.derivatives #=> {}
462
+ #
463
+ # # With keep_derivatives: true
464
+ # attacher.change(file)
465
+ # attacher.derivatives #=> { thumb: #<Shrine::UploadedFile> }
462
466
  def change(*)
463
467
  result = super
464
- set_derivatives({})
468
+ set_derivatives({}) unless shrine_class.derivatives_options[:keep_derivatives]
465
469
  result
466
470
  end
467
471
 
468
472
  # Sets a hash of derivatives.
469
473
  #
470
- # attacher.derivatives = { thumb: Shrine.uploaded_file(...) }
474
+ # attacher.derivatives = { thumb: Shrine.uploaded_file(data) }
471
475
  # attacher.derivatives #=> { thumb: #<Shrine::UploadedFile ...> }
472
476
  def derivatives=(derivatives)
473
477
  unless derivatives.is_a?(Hash)
@@ -479,7 +483,9 @@ class Shrine
479
483
 
480
484
  # Iterates through nested derivatives and maps results.
481
485
  #
482
- # attacher.map_derivative(derivatives) { |path, file| ... }
486
+ # attacher.map_derivative(derivatives) do |path, file|
487
+ # # ...
488
+ # end
483
489
  def map_derivative(derivatives, **options, &block)
484
490
  shrine_class.map_derivative(derivatives, **options, &block)
485
491
  end
@@ -544,6 +550,14 @@ class Shrine
544
550
  def create_derivatives_on_promote?
545
551
  shrine_class.derivatives_options[:create_on_promote]
546
552
  end
553
+
554
+ def derivatives_synchronize
555
+ if @derivatives_mutex
556
+ @derivatives_mutex.synchronize { yield }
557
+ else
558
+ yield
559
+ end
560
+ end
547
561
  end
548
562
 
549
563
  module ClassMethods
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+ require 'base64'
5
+
3
6
  class Shrine
4
7
  module Plugins
5
8
  # Documentation can be found on https://shrinerb.com/docs/plugins/download_endpoint
@@ -16,11 +19,11 @@ class Shrine
16
19
 
17
20
  module ClassMethods
18
21
  # Returns the Rack application that retrieves requested files.
19
- def download_endpoint(**options)
22
+ def download_endpoint(**)
20
23
  Shrine::DownloadEndpoint.new(
21
24
  shrine_class: self,
22
25
  **opts[:download_endpoint],
23
- **options,
26
+ **,
24
27
  )
25
28
  end
26
29
 
@@ -30,7 +33,7 @@ class Shrine
30
33
  # It uses a trick where it removes the download path prefix from the
31
34
  # path info before calling the Rack app, which is what web framework
32
35
  # routers do before they're calling a mounted Rack app.
33
- def download_response(env, **options)
36
+ def download_response(env, **)
34
37
  script_name = env["SCRIPT_NAME"]
35
38
  path_info = env["PATH_INFO"]
36
39
 
@@ -43,7 +46,7 @@ class Shrine
43
46
  env["SCRIPT_NAME"] += match.to_s
44
47
  env["PATH_INFO"] = match.post_match
45
48
 
46
- download_endpoint(**options).call(env)
49
+ download_endpoint(**).call(env)
47
50
  ensure
48
51
  env["SCRIPT_NAME"] = script_name
49
52
  env["PATH_INFO"] = path_info
@@ -53,8 +56,8 @@ class Shrine
53
56
 
54
57
  module FileMethods
55
58
  # Returns file URL on the download endpoint.
56
- def download_url(**options)
57
- FileUrl.new(self).call(**options)
59
+ def download_url(**)
60
+ FileUrl.new(self).call(**)
58
61
  end
59
62
  end
60
63
 
@@ -65,14 +68,36 @@ class Shrine
65
68
  @file = file
66
69
  end
67
70
 
68
- def call(host: self.host)
69
- [host, *prefix, path].join("/")
71
+ def call(host: self.host, expires_in: nil)
72
+ path = file.urlsafe_dump(metadata: %w[filename size mime_type])
73
+
74
+ query = signature_as_query(path: path, expires_in: expires_in)
75
+
76
+ path = [host, *prefix, path].join("/")
77
+ path += "?#{query}" if query
78
+ path
70
79
  end
71
80
 
72
81
  protected
73
82
 
74
- def path
75
- file.urlsafe_dump(metadata: %w[filename size mime_type])
83
+ def signature_as_query(path:, expires_in:)
84
+ expires_in = default_expires_in if expires_in.nil?
85
+ raise(Error, "secret_key is required for expiring URLs") if !secret_key && expires_in
86
+ raise(Error, "expires_in is required for expiring URLs") if secret_key && !expires_in
87
+
88
+ return nil unless expires_in
89
+
90
+ expires_at = (Time.now + expires_in).to_i
91
+ signature = OpenSSL::HMAC.digest(
92
+ OpenSSL::Digest::SHA256.new,
93
+ secret_key,
94
+ "#{path}--#{expires_at}"
95
+ )
96
+
97
+ Rack::Utils.build_query(
98
+ signature: Base64.urlsafe_encode64(signature),
99
+ expires_at: expires_at
100
+ )
76
101
  end
77
102
 
78
103
  def host
@@ -83,6 +108,14 @@ class Shrine
83
108
  options[:prefix]
84
109
  end
85
110
 
111
+ def default_expires_in
112
+ options[:expires_in]
113
+ end
114
+
115
+ def secret_key
116
+ options[:secret_key]
117
+ end
118
+
86
119
  def options
87
120
  file.shrine_class.opts[:download_endpoint]
88
121
  end
@@ -113,7 +146,9 @@ class Shrine
113
146
  handle_request(request)
114
147
  end
115
148
 
116
- headers["Content-Length"] ||= body.map(&:bytesize).inject(0, :+).to_s
149
+ headers = Rack::Headers[headers] if Rack.release >= "3"
150
+ headers["Content-Length"] ||= body.respond_to?(:bytesize) ? body.bytesize.to_s :
151
+ body.map(&:bytesize).inject(0, :+).to_s
117
152
 
118
153
  [status, headers, body]
119
154
  end
@@ -127,6 +162,9 @@ class Shrine
127
162
 
128
163
  def handle_request(request)
129
164
  _, serialized, * = request.path_info.split("/")
165
+ signature, expires_at = request.params.values_at("signature", "expires_at")
166
+
167
+ check_signature!(serialized, signature, expires_at) if @secret_key
130
168
 
131
169
  uploaded_file = get_uploaded_file(serialized)
132
170
 
@@ -182,12 +220,35 @@ class Shrine
182
220
  @shrine_class::UploadedFile.urlsafe_load(serialized)
183
221
  rescue Shrine::Error # storage not found
184
222
  not_found!
223
+ rescue JSON::ParserError, ArgumentError => error # invalid serialized component
224
+ raise if error.is_a?(ArgumentError) && error.message != "invalid base64"
225
+ bad_request!("Invalid serialized file")
226
+ end
227
+
228
+ def check_signature!(serialized, signature, expires_at)
229
+ if expires_at && expires_at.to_i < Time.now.to_i
230
+ error!(400, "URL has expired")
231
+ end
232
+
233
+ calculated_signature = OpenSSL::HMAC.digest(
234
+ OpenSSL::Digest::SHA256.new,
235
+ @secret_key,
236
+ "#{serialized}--#{expires_at}"
237
+ )
238
+
239
+ if !Rack::Utils.secure_compare(signature, Base64.urlsafe_encode64(calculated_signature))
240
+ error!(403, "Signature does not match")
241
+ end
185
242
  end
186
243
 
187
244
  def not_found!
188
245
  error!(404, "File Not Found")
189
246
  end
190
247
 
248
+ def bad_request!(message)
249
+ error!(400, message)
250
+ end
251
+
191
252
  # Halts the request with the error message.
192
253
  def error!(status, message)
193
254
  throw :halt, [status, { "Content-Type" => "text/plain" }, [message]]