shrine 1.4.2 → 2.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.

Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +236 -234
  3. data/doc/changing_location.md +6 -4
  4. data/doc/creating_storages.md +4 -4
  5. data/doc/design.md +223 -0
  6. data/doc/migrating_storage.md +6 -11
  7. data/doc/regenerating_versions.md +22 -40
  8. data/lib/shrine.rb +60 -77
  9. data/lib/shrine/plugins/activerecord.rb +37 -14
  10. data/lib/shrine/plugins/background_helpers.rb +1 -0
  11. data/lib/shrine/plugins/backgrounding.rb +49 -37
  12. data/lib/shrine/plugins/backup.rb +6 -4
  13. data/lib/shrine/plugins/cached_attachment_data.rb +5 -5
  14. data/lib/shrine/plugins/data_uri.rb +9 -9
  15. data/lib/shrine/plugins/default_storage.rb +4 -4
  16. data/lib/shrine/plugins/default_url.rb +7 -1
  17. data/lib/shrine/plugins/default_url_options.rb +1 -1
  18. data/lib/shrine/plugins/delete_promoted.rb +2 -2
  19. data/lib/shrine/plugins/delete_raw.rb +4 -4
  20. data/lib/shrine/plugins/determine_mime_type.rb +50 -43
  21. data/lib/shrine/plugins/direct_upload.rb +10 -20
  22. data/lib/shrine/plugins/download_endpoint.rb +16 -13
  23. data/lib/shrine/plugins/dynamic_storage.rb +4 -12
  24. data/lib/shrine/plugins/included.rb +6 -19
  25. data/lib/shrine/plugins/keep_files.rb +4 -4
  26. data/lib/shrine/plugins/logging.rb +4 -4
  27. data/lib/shrine/plugins/migration_helpers.rb +37 -34
  28. data/lib/shrine/plugins/moving.rb +19 -32
  29. data/lib/shrine/plugins/parallelize.rb +5 -5
  30. data/lib/shrine/plugins/pretty_location.rb +2 -6
  31. data/lib/shrine/plugins/remote_url.rb +31 -43
  32. data/lib/shrine/plugins/remove_attachment.rb +5 -5
  33. data/lib/shrine/plugins/remove_invalid.rb +1 -1
  34. data/lib/shrine/plugins/restore_cached_data.rb +4 -10
  35. data/lib/shrine/plugins/sequel.rb +46 -21
  36. data/lib/shrine/plugins/store_dimensions.rb +19 -20
  37. data/lib/shrine/plugins/upload_options.rb +11 -9
  38. data/lib/shrine/plugins/validation_helpers.rb +3 -3
  39. data/lib/shrine/plugins/versions.rb +18 -3
  40. data/lib/shrine/storage/file_system.rb +9 -11
  41. data/lib/shrine/storage/linter.rb +1 -7
  42. data/lib/shrine/storage/s3.rb +25 -19
  43. data/lib/shrine/version.rb +3 -3
  44. data/shrine.gemspec +13 -3
  45. metadata +28 -9
  46. data/lib/shrine/plugins/delete_uploaded.rb +0 -3
  47. data/lib/shrine/plugins/keep_location.rb +0 -46
  48. data/lib/shrine/plugins/restore_cached.rb +0 -3
@@ -15,65 +15,54 @@ class Shrine
15
15
  # user.avatar.size #=> 43423
16
16
  # user.avatar.original_filename #=> "cool-image.png"
17
17
  #
18
- # The file will by default be downloaded using Ruby's open-uri standard
19
- # library. Following redirects is disabled for security reasons. It's also
20
- # good practice to limit the filesize of the remote file:
18
+ # The file will by default be downloaded using [Down], which is a wrapper
19
+ # around the open-uri standard library. It's a good practice to limit the
20
+ # maximum filesize of the remote file:
21
21
  #
22
22
  # plugin :remote_url, max_size: 20*1024*1024 # 20 MB
23
23
  #
24
- # Now if a file that is bigger than 20MB is assigned, Shrine will terminate
25
- # the download as soon as it gets the "Content-Length" header, or the
26
- # buffer size surpasses the maximum size. However, if for whatever reason
27
- # you don't want to limit the maximum file size, you can set `:max_size` to
28
- # nil:
24
+ # Now if a file that is bigger than 20MB is assigned, download will be
25
+ # terminated as soon as it gets the "Content-Length" header, or the
26
+ # size of currently downloaded content surpasses the maximum size.
27
+ # However, if for whatever reason you don't want to limit the maximum file
28
+ # size, you can set `:max_size` to nil:
29
29
  #
30
30
  # plugin :remote_url, max_size: nil
31
31
  #
32
- # Finally, if for some reason the way the file is downloaded doesn't suit
33
- # your needs, you can provide a custom downloader:
32
+ # If you need to additionally customize how the file is downloaded, you can
33
+ # override the `:downloader`:
34
34
  #
35
- # plugin :remote_url, downloader: ->(url, max_size:) do
36
- # request = RestClient::Request.new(method: :get, url: url, raw_response: true)
37
- # response = request.execute
38
- # response.file
35
+ # plugin :remote_url, max_size: 20*1024*1024, downloader: ->(url, max_size:) do
36
+ # Down.download(url, max_size: max_size, max_redirects: 4, read_timeout: 3)
39
37
  # end
40
38
  #
41
39
  # If download errors, the error is rescued and a validation error is added
42
40
  # equal to the error message. You can change the default error message:
43
41
  #
44
42
  # plugin :remote_url, error_message: "download failed"
45
- # plugin :remote_url, error_message: ->(url) { I18n.t("errors.download_failed") }
43
+ # plugin :remote_url, error_message: ->(url, error) { I18n.t("errors.download_failed") }
46
44
  #
47
- # If you need the error instance for generating the error message, passing
48
- # the `:include_error` option will additionally yield the error to the
49
- # block:
50
- #
51
- # plugin :remote_url, include_error: true, error_message: ->(url, error) { "..." }
45
+ # [Down]: https://github.com/janko-m/down
52
46
  module RemoteUrl
53
- def self.load_dependencies(uploader, downloader: :open_uri, **)
54
- case downloader
55
- when :open_uri then require "down"
56
- end
57
- end
47
+ def self.configure(uploader, opts = {})
48
+ raise Error, "The :max_size option is required for remote_url plugin" if !opts.key?(:max_size) && !uploader.opts.key?(:remote_url_max_size)
58
49
 
59
- def self.configure(uploader, downloader: :open_uri, max_size:, error_message: nil, include_error: false)
60
- uploader.opts[:remote_url_downloader] = downloader
61
- uploader.opts[:remote_url_max_size] = max_size
62
- uploader.opts[:remote_url_error_message] = error_message
63
- uploader.opts[:remote_url_include_error] = include_error
50
+ uploader.opts[:remote_url_downloader] = opts.fetch(:downloader, uploader.opts.fetch(:remote_url_downloader, :open_uri))
51
+ uploader.opts[:remote_url_max_size] = opts.fetch(:max_size, uploader.opts[:remote_url_max_size])
52
+ uploader.opts[:remote_url_error_message] = opts.fetch(:error_message, uploader.opts[:remote_url_error_message])
64
53
  end
65
54
 
66
55
  module AttachmentMethods
67
- def initialize(name)
56
+ def initialize(*)
68
57
  super
69
58
 
70
59
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
71
- def #{name}_remote_url=(url)
72
- #{name}_attacher.remote_url = url
60
+ def #{@name}_remote_url=(url)
61
+ #{@name}_attacher.remote_url = url
73
62
  end
74
63
 
75
- def #{name}_remote_url
76
- #{name}_attacher.remote_url
64
+ def #{@name}_remote_url
65
+ #{@name}_attacher.remote_url
77
66
  end
78
67
  RUBY
79
68
  end
@@ -113,26 +102,25 @@ class Shrine
113
102
  # is too big.
114
103
  def download(url)
115
104
  downloader = shrine_class.opts[:remote_url_downloader]
105
+ downloader = method(:"download_with_#{downloader}") if downloader.is_a?(Symbol)
116
106
  max_size = shrine_class.opts[:remote_url_max_size]
117
107
 
118
- if downloader.is_a?(Symbol)
119
- send(:"download_with_#{downloader}", url, max_size: max_size)
120
- else
121
- downloader.call(url, max_size: max_size)
122
- end
108
+ downloader.call(url, max_size: max_size)
123
109
  end
124
110
 
125
111
  # We silence any download errors, because for the user's point of view
126
112
  # the download simply failed.
127
113
  def download_with_open_uri(url, max_size:)
114
+ require "down"
128
115
  Down.download(url, max_size: max_size)
129
116
  end
130
117
 
131
118
  def download_error_message(url, error)
132
119
  if message = shrine_class.opts[:remote_url_error_message]
133
- args = [url]
134
- args << error if shrine_class.opts[:remote_url_include_error]
135
- message = message.call(*args) if message.respond_to?(:call)
120
+ if message.respond_to?(:call)
121
+ args = [url, error].take(message.arity.abs)
122
+ message = message.call(*args)
123
+ end
136
124
  else
137
125
  message = "download failed"
138
126
  message = "#{message}: #{error.message}" if error
@@ -20,16 +20,16 @@ class Shrine
20
20
  # declared somewhere after the hidden field.
21
21
  module RemoveAttachment
22
22
  module AttachmentMethods
23
- def initialize(name)
23
+ def initialize(*)
24
24
  super
25
25
 
26
26
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
27
- def remove_#{name}=(value)
28
- #{name}_attacher.remove = value
27
+ def remove_#{@name}=(value)
28
+ #{@name}_attacher.remove = value
29
29
  end
30
30
 
31
- def remove_#{name}
32
- #{name}_attacher.remove
31
+ def remove_#{@name}
32
+ #{@name}_attacher.remove
33
33
  end
34
34
  RUBY
35
35
  end
@@ -10,7 +10,7 @@ class Shrine
10
10
  super
11
11
  ensure
12
12
  if errors.any? && cache.uploaded?(get)
13
- delete!(get, phase: :invalid)
13
+ _delete(get, phase: :validate)
14
14
  _set(nil)
15
15
  end
16
16
  end
@@ -1,14 +1,9 @@
1
1
  class Shrine
2
2
  module Plugins
3
- # The restore_cached_data plugin ensures the cached file data hasn't been
4
- # tampered with, by restoring the metadata after assignment. The user can
5
- # tamper with the cached file data by modifying the hidden field before
6
- # submitting the form.
7
- #
8
- # Firstly the assignment is terminated if the cached file doesn't exist,
9
- # which can happen if the user changes the "id" or "storage" data. If the
10
- # cached file exists, the metadata is reextracted from the original file
11
- # and replaced with the potentially tampered with ones.
3
+ # The restore_cached_data plugin ensures the cached file metadata hasn't
4
+ # been tampered with, which can be done by modifying the hidden field
5
+ # before submitting the form. The cached file's metadata will be
6
+ # reextracted during assignment and replaced with potentially tampered one.
12
7
  #
13
8
  # plugin :restore_cached_data
14
9
  module RestoreCachedData
@@ -18,7 +13,6 @@ class Shrine
18
13
  def assign_cached(value)
19
14
  cached_file = uploaded_file(value) do |cached_file|
20
15
  next unless cache.uploaded?(cached_file)
21
- return unless cached_file.exists?
22
16
  real_metadata = cache.extract_metadata(cached_file.to_io, context)
23
17
  cached_file.metadata.update(real_metadata)
24
18
  cached_file.close
@@ -1,7 +1,5 @@
1
1
  require "sequel"
2
2
 
3
- Sequel::Model.plugin :instance_filters
4
-
5
3
  class Shrine
6
4
  module Plugins
7
5
  # The sequel plugin extends the "attachment" interface with support for
@@ -9,10 +7,11 @@ class Shrine
9
7
  #
10
8
  # plugin :sequel
11
9
  #
12
- # Now whenever an "attachment" module is included, additional callbacks are
13
- # added to the model:
10
+ # ## Callbacks
11
+ #
12
+ # Now the attachment module will add additional callbacks to the model:
14
13
  #
15
- # * `before_save` -- Currently only used by the recache plugin.
14
+ # * `before_save` -- Used by the recached plugin.
16
15
  # * `after_commit` -- Promotes the attachment, deletes replaced ones.
17
16
  # * `after_destroy_commit` -- Deletes the attachment.
18
17
  #
@@ -20,8 +19,29 @@ class Shrine
20
19
  # `after_commit` callbacks won't get called, so in order to test uploading
21
20
  # you should first disable transactions for those tests.
22
21
  #
23
- # If you want to put some parts of this lifecycle into a background job,
24
- # see the backgrounding plugin.
22
+ # If you want to put promoting/deleting into a background job, see the
23
+ # backgrounding plugin.
24
+ #
25
+ # Since attaching first saves the record with a cached attachment, then
26
+ # saves again with a stored attachment, you can detect this in callbacks:
27
+ #
28
+ # class User < Sequel::Model
29
+ # include ImageUploader[:avatar]
30
+ #
31
+ # def before_save
32
+ # super
33
+ #
34
+ # if changed_columns.include?(:avatar) && avatar_attacher.cached?
35
+ # # cached
36
+ # end
37
+ #
38
+ # if changed_columns.include?(:avatar) && avatar_attacher.stored?
39
+ # # promoted
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # ## Validations
25
45
  #
26
46
  # Additionally, any Shrine validation errors will added to Sequel's
27
47
  # errors upon validation. Note that if you want to validate presence of the
@@ -33,30 +53,32 @@ class Shrine
33
53
  # end
34
54
  module Sequel
35
55
  module AttachmentMethods
36
- def initialize(name)
56
+ def included(model)
37
57
  super
38
58
 
39
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
59
+ return unless model < ::Sequel::Model
60
+
61
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
40
62
  def validate
41
63
  super
42
- #{name}_attacher.errors.each do |message|
43
- errors.add(:#{name}, message)
64
+ #{@name}_attacher.errors.each do |message|
65
+ errors.add(:#{@name}, message)
44
66
  end
45
67
  end
46
68
 
47
69
  def before_save
48
70
  super
49
- #{name}_attacher.save if #{name}_attacher.attached?
71
+ #{@name}_attacher.save if #{@name}_attacher.attached?
50
72
  end
51
73
 
52
74
  def after_commit
53
75
  super
54
- #{name}_attacher.finalize if #{name}_attacher.attached?
76
+ #{@name}_attacher.finalize if #{@name}_attacher.attached?
55
77
  end
56
78
 
57
79
  def after_destroy_commit
58
80
  super
59
- #{name}_attacher.destroy
81
+ #{@name}_attacher.destroy
60
82
  end
61
83
  RUBY
62
84
  end
@@ -72,18 +94,21 @@ class Shrine
72
94
  module AttacherMethods
73
95
  private
74
96
 
75
- # Updates the current attachment with the new one, unless the current
76
- # attachment has changed.
77
- def update(uploaded_file)
78
- if record.send("#{name}_data") == record.reload.send("#{name}_data")
79
- record.send("#{name}_data=", uploaded_file.to_json)
80
- record.save(validate: false)
81
- end
97
+ # Proceeds with updating the record unless the attachment has changed.
98
+ def swap(uploaded_file)
99
+ return if record.send(:"#{name}_data") != record.reload.send(:"#{name}_data")
100
+ super
82
101
  rescue ::Sequel::NoExistingObject
83
102
  rescue ::Sequel::Error => error
84
103
  raise unless error.message == "Record not found" # prior to version 4.28
85
104
  end
86
105
 
106
+ # Saves the record after assignment, skipping validations.
107
+ def update(uploaded_file)
108
+ super
109
+ record.save(validate: false)
110
+ end
111
+
87
112
  # Support for Postgres JSON columns.
88
113
  def read
89
114
  value = super
@@ -17,21 +17,16 @@ class Shrine
17
17
  # if for some reason it doesn't suit your needs, you can provide a custom
18
18
  # `:analyzer`:
19
19
  #
20
- # plugin :store_dimensions, analyzer: ->(io) do
21
- # MiniMagick::Image.new(io).dimensions #=> [300, 500]
20
+ # plugin :store_dimensions, analyzer: ->(io, analyzers) do
21
+ # dimensions = analyzers[:fastimage].call(io)
22
+ # dimensions || MiniMagick::Image.new(io).dimensions
22
23
  # end
23
24
  #
24
25
  # [fastimage]: https://github.com/sdsykes/fastimage
25
26
  # [image bombs]: https://www.bamsoftware.com/hacks/deflate.html
26
27
  module StoreDimensions
27
- def self.load_dependencies(uploader, analyzer: :fastimage)
28
- case analyzer
29
- when :fastimage then require "fastimage"
30
- end
31
- end
32
-
33
- def self.configure(uploader, analyzer: :fastimage)
34
- uploader.opts[:dimensions_analyzer] = analyzer
28
+ def self.configure(uploader, opts = {})
29
+ uploader.opts[:dimensions_analyzer] = opts.fetch(:analyzer, uploader.opts.fetch(:dimensions_analyzer, :fastimage))
35
30
  end
36
31
 
37
32
  module InstanceMethods
@@ -45,28 +40,32 @@ class Shrine
45
40
  )
46
41
  end
47
42
 
43
+ private
44
+
48
45
  # If the `io` is an uploaded file, copies its dimensions, otherwise
49
46
  # calls the predefined or custom analyzer.
50
47
  def extract_dimensions(io)
51
48
  analyzer = opts[:dimensions_analyzer]
49
+ analyzer = dimensions_analyzers[analyzer] if analyzer.is_a?(Symbol)
50
+ args = [io, dimensions_analyzers].take(analyzer.arity.abs)
52
51
 
53
- dimensions = if io.respond_to?(:width) && io.respond_to?(:height)
54
- [io.width, io.height]
55
- elsif analyzer.is_a?(Symbol)
56
- send(:"_extract_dimensions_with_#{analyzer}", io)
57
- else
58
- analyzer.call(io)
59
- end
60
-
52
+ dimensions = analyzer.call(*args)
61
53
  io.rewind
62
54
 
63
55
  dimensions
64
56
  end
65
57
 
66
- private
58
+ def dimensions_analyzers
59
+ Hash.new { |hash, key| method(:"_extract_dimensions_with_#{key}") }
60
+ end
67
61
 
68
62
  def _extract_dimensions_with_fastimage(io)
69
- FastImage.size(io)
63
+ require "fastimage"
64
+
65
+ dimensions = FastImage.size(io)
66
+ io.rewind
67
+
68
+ dimensions
70
69
  end
71
70
  end
72
71
 
@@ -1,7 +1,12 @@
1
1
  class Shrine
2
2
  module Plugins
3
- # The upload_options allows you to create additional upload options depending
4
- # on file and context, and forward them to the underlying storage.
3
+ # The upload_options allows you to automatically pass additional upload
4
+ # options to storage on every upload:
5
+ #
6
+ # plugin :upload_options, cache: {acl: "private"}
7
+ #
8
+ # Keys are names of the registered storages, and values are either hashes
9
+ # or blocks.
5
10
  #
6
11
  # plugin :upload_options, store: ->(io, context) do
7
12
  # if [:original, :thumb].include?(context[:version])
@@ -10,26 +15,23 @@ class Shrine
10
15
  # {acl: "private"}
11
16
  # end
12
17
  # end
13
- #
14
- # Keys are names of the registered storages, and values are either blocks
15
- # or hashes.
16
18
  module UploadOptions
17
19
  def self.configure(uploader, options = {})
18
- uploader.opts[:upload_options_options] = options
20
+ uploader.opts[:upload_options] ||= {}
21
+ options.each { |key, value| uploader.opts[:upload_options][key] = value }
19
22
  end
20
23
 
21
24
  module InstanceMethods
22
25
  def put(io, context)
23
26
  upload_options = get_upload_options(io, context)
24
- key = storage.class.name.split("::").last.downcase
25
- context[:metadata][key] = upload_options if upload_options
27
+ context = {upload_options: upload_options}.merge(context)
26
28
  super
27
29
  end
28
30
 
29
31
  private
30
32
 
31
33
  def get_upload_options(io, context)
32
- options = opts[:upload_options_options][storage_key]
34
+ options = opts[:upload_options][storage_key]
33
35
  options = options.call(io, context) if options.respond_to?(:call)
34
36
  options
35
37
  end
@@ -32,8 +32,8 @@ class Shrine
32
32
  #
33
33
  # For a complete list of all validation helpers, see AttacherMethods.
34
34
  module ValidationHelpers
35
- def self.configure(uploader, default_messages: {})
36
- uploader.opts[:validation_helpers_default_messages] = default_messages
35
+ def self.configure(uploader, opts = {})
36
+ uploader.opts[:validation_default_messages] = (uploader.opts[:validation_default_messages] || {}).merge(opts[:default_messages] || {})
37
37
  end
38
38
 
39
39
  DEFAULT_MESSAGES = {
@@ -52,7 +52,7 @@ class Shrine
52
52
  module AttacherClassMethods
53
53
  def default_validation_messages
54
54
  @default_validation_messages ||= DEFAULT_MESSAGES.merge(
55
- shrine_class.opts[:validation_helpers_default_messages])
55
+ shrine_class.opts[:validation_default_messages])
56
56
  end
57
57
  end
58
58
 
@@ -22,6 +22,18 @@ class Shrine
22
22
  # end
23
23
  # end
24
24
  #
25
+ # Note that if you want to keep the original file, you can forward it as is
26
+ # without explicitly downloading it (since `Shrine::UploadedFile` itself is
27
+ # an IO-like object), which might avoid downloading depending on the
28
+ # storage:
29
+ #
30
+ # def process(io, context)
31
+ # if context[:phase] == :store
32
+ # # ...
33
+ # {original: io, thumb: thumb}
34
+ # end
35
+ # end
36
+ #
25
37
  # Now when you access the stored attachment through the model, a hash of
26
38
  # uploaded files will be returned:
27
39
  #
@@ -96,11 +108,14 @@ class Shrine
96
108
  module Versions
97
109
  def self.load_dependencies(uploader, *)
98
110
  uploader.plugin :multi_delete
111
+ uploader.plugin :default_url
99
112
  end
100
113
 
101
- def self.configure(uploader, names:, fallbacks: {})
102
- uploader.opts[:version_names] = names
103
- uploader.opts[:version_fallbacks] = fallbacks
114
+ def self.configure(uploader, opts = {})
115
+ uploader.opts[:version_names] = opts.fetch(:names, uploader.opts[:version_names])
116
+ uploader.opts[:version_fallbacks] = opts.fetch(:fallbacks, uploader.opts.fetch(:version_fallbacks, {}))
117
+
118
+ raise Error, "The :names option is required for versions plugin" if uploader.opts[:version_names].nil?
104
119
  end
105
120
 
106
121
  module ClassMethods