shrine 2.5.0 → 2.6.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -13
  3. data/doc/attacher.md +7 -6
  4. data/doc/carrierwave.md +19 -17
  5. data/doc/design.md +1 -1
  6. data/doc/direct_s3.md +8 -5
  7. data/doc/multiple_files.md +4 -4
  8. data/doc/paperclip.md +7 -6
  9. data/doc/refile.md +67 -4
  10. data/doc/securing_uploads.md +41 -25
  11. data/doc/testing.md +6 -15
  12. data/lib/shrine.rb +19 -10
  13. data/lib/shrine/plugins/activerecord.rb +4 -4
  14. data/lib/shrine/plugins/add_metadata.rb +7 -3
  15. data/lib/shrine/plugins/background_helpers.rb +1 -1
  16. data/lib/shrine/plugins/backgrounding.rb +19 -6
  17. data/lib/shrine/plugins/cached_attachment_data.rb +4 -4
  18. data/lib/shrine/plugins/data_uri.rb +105 -31
  19. data/lib/shrine/plugins/default_url.rb +1 -1
  20. data/lib/shrine/plugins/delete_raw.rb +7 -3
  21. data/lib/shrine/plugins/determine_mime_type.rb +96 -44
  22. data/lib/shrine/plugins/direct_upload.rb +3 -1
  23. data/lib/shrine/plugins/download_endpoint.rb +14 -5
  24. data/lib/shrine/plugins/logging.rb +4 -4
  25. data/lib/shrine/plugins/metadata_attributes.rb +61 -0
  26. data/lib/shrine/plugins/migration_helpers.rb +1 -1
  27. data/lib/shrine/plugins/rack_file.rb +54 -30
  28. data/lib/shrine/plugins/recache.rb +1 -1
  29. data/lib/shrine/plugins/refresh_metadata.rb +29 -0
  30. data/lib/shrine/plugins/remote_url.rb +26 -4
  31. data/lib/shrine/plugins/remove_invalid.rb +5 -4
  32. data/lib/shrine/plugins/restore_cached_data.rb +10 -13
  33. data/lib/shrine/plugins/sequel.rb +4 -4
  34. data/lib/shrine/plugins/signature.rb +146 -0
  35. data/lib/shrine/plugins/store_dimensions.rb +68 -24
  36. data/lib/shrine/plugins/validation_helpers.rb +48 -29
  37. data/lib/shrine/plugins/versions.rb +16 -8
  38. data/lib/shrine/storage/file_system.rb +27 -16
  39. data/lib/shrine/storage/s3.rb +99 -58
  40. data/lib/shrine/version.rb +1 -1
  41. data/shrine.gemspec +1 -1
  42. metadata +9 -6
@@ -6,7 +6,7 @@ class Shrine
6
6
  # plugin :validation_helpers
7
7
  #
8
8
  # Attacher.validate do
9
- # validat_mime_type_inclusion %w[image/jpeg image/png image/gif]
9
+ # validate_mime_type_inclusion %w[image/jpeg image/png image/gif]
10
10
  # validate_max_size 5*1024*1024 if record.guest?
11
11
  # end
12
12
  #
@@ -33,7 +33,7 @@ class Shrine
33
33
  # If you would like to change the error message inline, you can pass the
34
34
  # `:message` option to any validation method:
35
35
  #
36
- # validate_mime_type_inclusion [/\Aimage/], message: "is not an image"
36
+ # validate_mime_type_inclusion %w[image/jpeg image/png image/gif], message: "must be JPEG, PNG or GIF"
37
37
  #
38
38
  # For a complete list of all validation helpers, see AttacherMethods.
39
39
  module ValidationHelpers
@@ -42,16 +42,16 @@ class Shrine
42
42
  end
43
43
 
44
44
  DEFAULT_MESSAGES = {
45
- max_size: ->(max) { "is larger than #{max.to_f/1024/1024} MB" },
46
- min_size: ->(min) { "is smaller than #{min.to_f/1024/1024} MB" },
47
- max_width: ->(max) { "is wider than #{max} px" },
48
- min_width: ->(min) { "is narrower than #{min} px" },
49
- max_height: ->(max) { "is taller than #{max} px" },
50
- min_height: ->(min) { "is shorter than #{min} px" },
51
- mime_type_inclusion: ->(list) { "isn't of allowed type: #{list.inspect}" },
52
- mime_type_exclusion: ->(list) { "is of forbidden type: #{list.inspect}" },
53
- extension_inclusion: ->(list) { "isn't in allowed format: #{list.inspect}" },
54
- extension_exclusion: ->(list) { "is in forbidden format: #{list.inspect}" },
45
+ max_size: ->(max) { "is too large (max is #{max.to_f/1024/1024} MB)" },
46
+ min_size: ->(min) { "is too small (min is #{min.to_f/1024/1024} MB)" },
47
+ max_width: ->(max) { "is too wide (max is #{max} px)" },
48
+ min_width: ->(min) { "is too narrow (min is #{min} px)" },
49
+ max_height: ->(max) { "is too tall (max is #{max} px)" },
50
+ min_height: ->(min) { "is too short (min is #{min} px)" },
51
+ mime_type_inclusion: ->(list) { "isn't of allowed type (allowed types: #{list.join(", ")})" },
52
+ mime_type_exclusion: ->(list) { "is of forbidden type" },
53
+ extension_inclusion: ->(list) { "isn't of allowed format (allowed formats: #{list.join(", ")})" },
54
+ extension_exclusion: ->(list) { "is of forbidden format" },
55
55
  }
56
56
 
57
57
  module AttacherClassMethods
@@ -76,61 +76,75 @@ class Shrine
76
76
  # `store_dimensions` plugin.
77
77
  def validate_max_width(max, message: nil)
78
78
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
79
- get.width <= max or add_error(:max_width, message, max) && false if get.width
79
+ if get.width
80
+ get.width <= max or add_error(:max_width, message, max) && false
81
+ else
82
+ Shrine.deprecation("Width of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if width is nil.")
83
+ end
80
84
  end
81
85
 
82
86
  # Validates that the file is not narrower than `min`. Requires the
83
87
  # `store_dimensions` plugin.
84
88
  def validate_min_width(min, message: nil)
85
89
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
86
- get.width >= min or add_error(:min_width, message, min) && false if get.width
90
+ if get.width
91
+ get.width >= min or add_error(:min_width, message, min) && false
92
+ else
93
+ Shrine.deprecation("Width of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if width is nil.")
94
+ end
87
95
  end
88
96
 
89
97
  # Validates that the file is not taller than `max`. Requires the
90
98
  # `store_dimensions` plugin.
91
99
  def validate_max_height(max, message: nil)
92
100
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
93
- get.height <= max or add_error(:max_height, message, max) && false if get.height
101
+ if get.height
102
+ get.height <= max or add_error(:max_height, message, max) && false
103
+ else
104
+ Shrine.deprecation("Height of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if height is nil.")
105
+ end
94
106
  end
95
107
 
96
108
  # Validates that the file is not shorter than `min`. Requires the
97
109
  # `store_dimensions` plugin.
98
110
  def validate_min_height(min, message: nil)
99
111
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
100
- get.height >= min or add_error(:min_height, message, min) && false if get.height
112
+ if get.height
113
+ get.height >= min or add_error(:min_height, message, min) && false
114
+ else
115
+ Shrine.deprecation("Height of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if height is nil.")
116
+ end
101
117
  end
102
118
 
103
- # Validates that the MIME type is in the `whitelist`. The whitelist is
104
- # an array of strings or regexes.
119
+ # Validates that the MIME type is in the given collection.
105
120
  #
106
- # validate_mime_type_inclusion ["audio/mp3", /\Avideo/]
121
+ # validate_mime_type_inclusion %w[audio/mp3 audio/flac]
107
122
  def validate_mime_type_inclusion(whitelist, message: nil)
108
123
  whitelist.any? { |mime_type| regex(mime_type) =~ get.mime_type.to_s } \
109
124
  or add_error(:mime_type_inclusion, message, whitelist) && false
110
125
  end
111
126
 
112
- # Validates that the MIME type is not in the `blacklist`. The blacklist
113
- # is an array of strings or regexes.
127
+ # Validates that the MIME type is not in the given collection.
114
128
  #
115
- # validate_mime_type_exclusion ["image/gif", /\Aaudio/]
129
+ # validate_mime_type_exclusion %w[text/x-php]
116
130
  def validate_mime_type_exclusion(blacklist, message: nil)
117
131
  blacklist.none? { |mime_type| regex(mime_type) =~ get.mime_type.to_s } \
118
132
  or add_error(:mime_type_exclusion, message, blacklist) && false
119
133
  end
120
134
 
121
- # Validates that the extension is in the `whitelist`. The whitelist
122
- # is an array of strings or regexes.
135
+ # Validates that the extension is in the given collection. Comparison
136
+ # is case insensitive.
123
137
  #
124
- # validate_extension_inclusion [/\Ajpe?g\z/i]
138
+ # validate_extension_inclusion %w[jpg jpeg png gif]
125
139
  def validate_extension_inclusion(whitelist, message: nil)
126
140
  whitelist.any? { |extension| regex(extension) =~ get.extension.to_s } \
127
141
  or add_error(:extension_inclusion, message, whitelist) && false
128
142
  end
129
143
 
130
- # Validates that the extension is not in the `blacklist`. The blacklist
131
- # is an array of strings or regexes.
144
+ # Validates that the extension is not in the given collection.
145
+ # Comparison is case insensitive.
132
146
  #
133
- # validate_extension_exclusion ["mov", /\Amp/i]
147
+ # validate_extension_exclusion %[php jar]
134
148
  def validate_extension_exclusion(blacklist, message: nil)
135
149
  blacklist.none? { |extension| regex(extension) =~ get.extension.to_s } \
136
150
  or add_error(:extension_exclusion, message, blacklist) && false
@@ -140,7 +154,12 @@ class Shrine
140
154
 
141
155
  # Converts a string to a regex.
142
156
  def regex(value)
143
- value.is_a?(Regexp) ? value : /\A#{Regexp.escape(value)}\z/i
157
+ if value.is_a?(Regexp)
158
+ Shrine.deprecation("Passing regexes to type/extension whitelists/blacklists in validation_helpers plugin is deprecated and will be removed in Shrine 3. Use strings instead.")
159
+ value
160
+ else
161
+ /\A#{Regexp.escape(value)}\z/i
162
+ end
144
163
  end
145
164
 
146
165
  # Generates an error message and appends it to errors array.
@@ -14,7 +14,7 @@ class Shrine
14
14
  # process(:store) do |io, context|
15
15
  # original = io.download
16
16
  #
17
- # size_800 = resize_to_limit!(original, 800, 800)
17
+ # size_800 = resize_to_limit!(original, 800, 800) { |cmd| cmd.auto_orient }
18
18
  # size_500 = resize_to_limit(size_800, 500, 500)
19
19
  # size_300 = resize_to_limit(size_500, 300, 300)
20
20
  #
@@ -49,6 +49,16 @@ class Shrine
49
49
  # user.avatar_url(:large)
50
50
  # user.avatar_url(:small, download: true) # with URL options
51
51
  #
52
+ # `Shrine.uploaded_file` will also instantiate a hash of
53
+ # `Shrine::UploadedFile` objects if given data with versions. If you want
54
+ # to apply a change to all files in an attachment, regardless of whether
55
+ # it consists of a single file or a hash of versions, you can pass a block
56
+ # to `Shrine.uploaded_file` and it will yield each file:
57
+ #
58
+ # Shrine.uploaded_file(attachment_data) do |uploaded_file|
59
+ # # ...
60
+ # end
61
+ #
52
62
  # ## Original file
53
63
  #
54
64
  # If you want to keep the original file, you can include the original
@@ -117,7 +127,7 @@ class Shrine
117
127
  end
118
128
 
119
129
  def self.configure(uploader, opts = {})
120
- warn "The versions Shrine plugin doesn't need the :names option anymore, you can safely remove it." if opts.key?(:names)
130
+ Shrine.deprecation("The versions Shrine plugin doesn't need the :names option anymore, you can safely remove it.") if opts.key?(:names)
121
131
 
122
132
  uploader.opts[:version_names] = opts.fetch(:names, uploader.opts[:version_names])
123
133
  uploader.opts[:version_fallbacks] = opts.fetch(:fallbacks, uploader.opts.fetch(:version_fallbacks, {}))
@@ -126,7 +136,7 @@ class Shrine
126
136
 
127
137
  module ClassMethods
128
138
  def version_names
129
- warn "Shrine.version_names is deprecated and will be removed in Shrine 3."
139
+ Shrine.deprecation("Shrine.version_names is deprecated and will be removed in Shrine 3.")
130
140
  opts[:version_names]
131
141
  end
132
142
 
@@ -136,7 +146,7 @@ class Shrine
136
146
 
137
147
  # Checks that the identifier is a registered version.
138
148
  def version?(name)
139
- warn "Shrine.version? is deprecated and will be removed in Shrine 3."
149
+ Shrine.deprecation("Shrine.version? is deprecated and will be removed in Shrine 3.")
140
150
  version_names.nil? || version_names.map(&:to_s).include?(name.to_s)
141
151
  end
142
152
 
@@ -171,9 +181,7 @@ class Shrine
171
181
  def _store(io, context)
172
182
  if (hash = io).is_a?(Hash)
173
183
  raise Error, ":location is not applicable to versions" if context.key?(:location)
174
- if duplicates = hash.keys.group_by{|k| hash[k]}.values.reject(&:one?).first
175
- raise Error, "The following version keys point to the same IO object: #{duplicates.inspect}. Shrine cannot upload the same IO object multiple times."
176
- end
184
+ raise Error, "detected multiple versions that point to the same IO object: given versions: #{hash.keys}, unique versions: #{hash.invert.invert.keys}" if hash.invert.invert != hash
177
185
 
178
186
  hash.inject({}) do |result, (name, version)|
179
187
  result.update(name => _store(version, version: name, **context))
@@ -233,7 +241,7 @@ class Shrine
233
241
 
234
242
  def assign_cached(value)
235
243
  cached_file = uploaded_file(value)
236
- warn "Assigning cached hash of files is deprecated for security reasons and will be removed in Shrine 3." if cached_file.is_a?(Hash)
244
+ Shrine.deprecation("Assigning cached hash of files is deprecated for security reasons and will be removed in Shrine 3.") if cached_file.is_a?(Hash)
237
245
  super(cached_file)
238
246
  end
239
247
 
@@ -9,10 +9,14 @@ class Shrine
9
9
  # storage = Shrine::Storage::FileSystem.new("public", prefix: "uploads")
10
10
  # storage.url("image.jpg") #=> "/uploads/image.jpg"
11
11
  #
12
- # This storage will upload all files to "public/uploads", and the URLs
13
- # of the uploaded files will start with "/uploads/*". This way you can
14
- # use FileSystem for both cache and store, one having the prefix
15
- # "uploads/cache" and other "uploads/store".
12
+ # This storage will upload all files to "public/uploads", and the URLs of
13
+ # the uploaded files will start with "/uploads/*". This way you can use
14
+ # FileSystem for both cache and store, one having the prefix
15
+ # "uploads/cache" and other "uploads/store". If you're uploading files
16
+ # to the `public` directory itself, you need to set `:prefix` to `"/"`:
17
+ #
18
+ # storage = Shrine::Storage::FileSystem.new("public", prefix: "/") # no prefix
19
+ # storage.url("image.jpg") #=> "/image.jpg"
16
20
  #
17
21
  # You can also initialize the storage just with the "base" directory, and
18
22
  # then the FileSystem storage will generate absolute URLs to files:
@@ -20,6 +24,10 @@ class Shrine
20
24
  # storage = Shrine::Storage::FileSystem.new(Dir.tmpdir)
21
25
  # storage.url("image.jpg") #=> "/var/folders/k7/6zx6dx6x7ys3rv3srh0nyfj00000gn/T/image.jpg"
22
26
  #
27
+ # In general you can always retrieve path to the file using `#path`:
28
+ #
29
+ # storage.path("image.jpg") #=> #<Pathname:public/image.jpg>
30
+ #
23
31
  # ## Host
24
32
  #
25
33
  # It's generally a good idea to serve your files via a CDN, so an
@@ -94,7 +102,7 @@ class Shrine
94
102
  # deleted, but if it happens that it causes too much load on the
95
103
  # filesystem, you can set this option to `false`.
96
104
  def initialize(directory, prefix: nil, host: nil, clean: true, permissions: 0644, directory_permissions: 0755)
97
- warn "The :host option to Shrine::Storage::FileSystem#initialize is deprecated and will be removed in Shrine 3. Pass :host to FileSystem#url instead, you can also use default_url_options plugin." if host
105
+ Shrine.deprecation("The :host option to Shrine::Storage::FileSystem#initialize is deprecated and will be removed in Shrine 3. Pass :host to FileSystem#url instead, you can also use default_url_options plugin.") if host
98
106
 
99
107
  if prefix
100
108
  @prefix = Pathname(relative(prefix))
@@ -127,7 +135,7 @@ class Shrine
127
135
  FileUtils.mv io.path, path!(id)
128
136
  else
129
137
  FileUtils.mv io.storage.path(io.id), path!(id)
130
- io.storage.clean(io.id) if io.storage.clean?
138
+ io.storage.clean(io.storage.path(io.id)) if io.storage.clean?
131
139
  end
132
140
  path(id).chmod(permissions) if permissions
133
141
  end
@@ -153,7 +161,7 @@ class Shrine
153
161
  # it's empty.
154
162
  def delete(id)
155
163
  path(id).delete
156
- clean(id) if clean?
164
+ clean(path(id)) if clean?
157
165
  rescue Errno::ENOENT
158
166
  end
159
167
 
@@ -173,7 +181,10 @@ class Shrine
173
181
  def clear!(older_than: nil)
174
182
  if older_than
175
183
  directory.find do |path|
176
- path.mtime < older_than ? path.rmtree : Find.prune
184
+ if path.file? && path.mtime < older_than
185
+ path.delete
186
+ clean(path) if clean?
187
+ end
177
188
  end
178
189
  else
179
190
  directory.rmtree
@@ -182,10 +193,15 @@ class Shrine
182
193
  end
183
194
  end
184
195
 
196
+ # Returns the full path to the file.
197
+ def path(id)
198
+ directory.join(id.gsub("/", File::SEPARATOR))
199
+ end
200
+
185
201
  # Catches the deprecated `#download` method.
186
202
  def method_missing(name, *args)
187
203
  if name == :download
188
- warn "Shrine::Storage::FileSystem#download is deprecated and will be removed in Shrine 3."
204
+ Shrine.deprecation("Shrine::Storage::FileSystem#download is deprecated and will be removed in Shrine 3.")
189
205
  require "down"
190
206
  open(*args) { |file| Down.copy_to_tempfile(*args, file) }
191
207
  else
@@ -195,14 +211,9 @@ class Shrine
195
211
 
196
212
  protected
197
213
 
198
- # Returns the full path to the file.
199
- def path(id)
200
- directory.join(id.gsub("/", File::SEPARATOR))
201
- end
202
-
203
214
  # Cleans all empty subdirectories up the hierarchy.
204
- def clean(id)
205
- path(id).dirname.ascend do |pathname|
215
+ def clean(path)
216
+ path.dirname.ascend do |pathname|
206
217
  if pathname.children.empty? && pathname != directory
207
218
  pathname.rmdir
208
219
  else
@@ -3,6 +3,8 @@ require "down"
3
3
  require "uri"
4
4
  require "cgi/util"
5
5
 
6
+ Aws.eager_autoload!(services: ["S3"])
7
+
6
8
  class Shrine
7
9
  module Storage
8
10
  # The S3 storage handles uploads to Amazon S3 service, using the [aws-sdk]
@@ -12,13 +14,25 @@ class Shrine
12
14
  #
13
15
  # It is initialized with the following 4 required options:
14
16
  #
15
- # Shrine::Storage::S3.new(
16
- # access_key_id: "xyz",
17
- # secret_access_key: "abc",
17
+ # storage = Shrine::Storage::S3.new(
18
+ # access_key_id: "abc",
19
+ # secret_access_key: "xyz",
18
20
  # region: "eu-west-1",
19
21
  # bucket: "my-app",
20
22
  # )
21
23
  #
24
+ # The storage exposes the underlying Aws objects:
25
+ #
26
+ # storage.client #=> #<Aws::S3::Client>
27
+ # storage.client.access_key_id #=> "abc"
28
+ # storage.client.secret_access_key #=> "xyz"
29
+ # storage.client.region #=> "eu-west-1"
30
+ #
31
+ # storage.bucket #=> #<Aws::S3::Bucket>
32
+ # storage.bucket.name #=> "my-app"
33
+ #
34
+ # storage.object("key") #=> #<Aws::S3::Object>
35
+ #
22
36
  # ## Prefix
23
37
  #
24
38
  # The `:prefix` option can be specified for uploading all files inside
@@ -33,7 +47,7 @@ class Shrine
33
47
  # Sometimes you'll want to add additional upload options to all S3 uploads.
34
48
  # You can do that by passing the `:upload` option:
35
49
  #
36
- # Shrine::Storage::S3.new(upload_options: {acl: "public-read"}, **s3_options)
50
+ # Shrine::Storage::S3.new(upload_options: {acl: "private"}, **s3_options)
37
51
  #
38
52
  # These options will be passed to aws-sdk's methods for [uploading],
39
53
  # [copying] and [presigning].
@@ -53,7 +67,7 @@ class Shrine
53
67
  #
54
68
  # or when using the uploader directly
55
69
  #
56
- # uploader.upload(file, upload_options: {acl: "public-read"})
70
+ # uploader.upload(file, upload_options: {acl: "private"})
57
71
  #
58
72
  # Note that, unlike the `:upload_options` storage option, upload options
59
73
  # given on the uploader level won't be forwarded for generating presigns,
@@ -64,21 +78,20 @@ class Shrine
64
78
  # This storage supports various URL options that will be forwarded from
65
79
  # uploaded file.
66
80
  #
67
- # s3.url(public: true) # public URL without signed parameters
68
- # s3.url(download: true) # forced download URL
81
+ # uploaded_file.url(public: true) # public URL without signed parameters
82
+ # uploaded_file.url(download: true) # forced download URL
69
83
  #
70
84
  # All other options are forwarded to the [aws-sdk] gem:
71
85
  #
72
- # s3.url(expires_in: 15)
73
- # s3.url(virtual_host: true)
86
+ # uploaded_file.url(expires_in: 15)
87
+ # uploaded_file.url(virtual_host: true)
74
88
  #
75
89
  # ## CDN
76
90
  #
77
91
  # If you're using a CDN with S3 like Amazon CloudFront, you can specify
78
92
  # the `:host` option to `#url`:
79
93
  #
80
- # s3 = Shrine::Storage::S3.new(**s3_options)
81
- # s3.url("image.jpg", host: "http://abc123.cloudfront.net")
94
+ # uploaded_file.url("image.jpg", host: "http://abc123.cloudfront.net")
82
95
  # #=> "http://abc123.cloudfront.net/image.jpg"
83
96
  #
84
97
  # ## Accelerate endpoint
@@ -103,13 +116,24 @@ class Shrine
103
116
  # ## Large files
104
117
  #
105
118
  # The [aws-sdk] gem has the ability to automatically use multipart
106
- # upload/copy for larger files, where the file is split into multiple chunks
107
- # which are uploaded/copied in parallel.
119
+ # upload/copy for larger files, splitting the file into multiple chunks
120
+ # and uploading/copying them in parallel.
121
+ #
122
+ # By default any files that are uploaded will use the multipart upload
123
+ # if they're larger than 15MB, and any files that are copied will use the
124
+ # multipart copy if they're larger than 150MB, but you can change the
125
+ # thresholds via `:multipart_threshold`.
108
126
  #
109
- # By default any files that are larger than 15MB will use this multipart
110
- # upload/copy, but you change this threshold:
127
+ # thresholds = {upload: 30*1024*1024, copy: 200*1024*1024}
128
+ # Shrine::Storage::S3.new(multipart_threshold: thresholds, **s3_options)
111
129
  #
112
- # Shrine::Storage::S3.new(multipart_threshold: 30*1024*1024) # 30MB
130
+ # If you want to change how many threads [aws-sdk] will use for multipart
131
+ # upload/copy, you can use the `upload_options` plugin to specify
132
+ # `:thread_count`.
133
+ #
134
+ # plugin :upload_options, store: ->(io, context) do
135
+ # {thread_count: 5}
136
+ # end
113
137
  #
114
138
  # ## Clearing cache
115
139
  #
@@ -124,7 +148,7 @@ class Shrine
124
148
  # [aws-sdk]: https://github.com/aws/aws-sdk-ruby
125
149
  # [Transfer Acceleration]: http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html
126
150
  class S3
127
- attr_reader :s3, :bucket, :prefix, :host, :upload_options
151
+ attr_reader :client, :bucket, :prefix, :host, :upload_options
128
152
 
129
153
  # Initializes a storage for uploading to S3.
130
154
  #
@@ -143,8 +167,10 @@ class Shrine
143
167
  # and [`Aws::S3::Bucket#presigned_post`].
144
168
  #
145
169
  # :multipart_threshold
146
- # : The file size over which the storage will use parallelized
147
- # multipart copy/upload. Default is `15*1024*1024` (15MB).
170
+ # : If the input file is larger than the specified size, a parallelized
171
+ # multipart will be used for the upload/copy. Defaults to
172
+ # `{upload: 15*1024*1024, copy: 100*1024*1024}` (15MB for upload
173
+ # requests, 100MB for copy requests).
148
174
  #
149
175
  # All other options are forwarded to [`Aws::S3::Client#initialize`].
150
176
  #
@@ -152,19 +178,35 @@ class Shrine
152
178
  # [`Aws::S3::Object#copy_from`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#copy_from-instance_method
153
179
  # [`Aws::S3::Bucket#presigned_post`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#presigned_post-instance_method
154
180
  # [`Aws::S3::Client#initialize`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html#initialize-instance_method
155
- def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold: 15*1024*1024, **s3_options)
156
- warn "The :host option to Shrine::Storage::S3#initialize is deprecated and will be removed in Shrine 3. Pass :host to S3#url instead, you can also use default_url_options plugin." if host
181
+ def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold: {}, **s3_options)
182
+ Shrine.deprecation("The :host option to Shrine::Storage::S3#initialize is deprecated and will be removed in Shrine 3. Pass :host to S3#url instead, you can also use default_url_options plugin.") if host
183
+ resource = Aws::S3::Resource.new(**s3_options)
157
184
 
185
+ if multipart_threshold.is_a?(Integer)
186
+ Shrine.deprecation("Accepting the :multipart_threshold S3 option as an integer is deprecated, use a hash with :upload and :copy keys instead, e.g. {upload: 15*1024*1024, copy: 150*1024*1024}")
187
+ multipart_threshold = {upload: multipart_threshold}
188
+ end
189
+ multipart_threshold[:upload] ||= 15*1024*1024
190
+ multipart_threshold[:copy] ||= 100*1024*1024
191
+
192
+ @bucket = resource.bucket(bucket)
193
+ @client = resource.client
158
194
  @prefix = prefix
159
- @s3 = Aws::S3::Resource.new(**s3_options)
160
- @bucket = @s3.bucket(bucket)
161
195
  @host = host
162
196
  @upload_options = upload_options
163
197
  @multipart_threshold = multipart_threshold
164
198
  end
165
199
 
200
+ # Returns an `Aws::S3::Resource` object.
201
+ def s3
202
+ Shrine.deprecation("Shrine::Storage::S3#s3 that returns an Aws::S3::Resource is deprecated, use Shrine::Storage::S3#client which returns an Aws::S3::Client object.")
203
+ Aws::S3::Resource.new(client: @client)
204
+ end
205
+
166
206
  # If the file is an UploadedFile from S3, issues a COPY command, otherwise
167
- # uploads the file.
207
+ # uploads the file. For files larger than `:multipart_threshold` a
208
+ # multipart upload/copy will be used for better performance and more
209
+ # resilient uploads.
168
210
  #
169
211
  # It assigns the correct "Content-Type" taken from the MIME type, because
170
212
  # by default S3 sets everything to "application/octet-stream".
@@ -189,8 +231,8 @@ class Shrine
189
231
 
190
232
  # Downloads the file from S3, and returns a `Tempfile`.
191
233
  def download(id)
192
- tempfile = Tempfile.new(["s3", File.extname(id)], binmode: true)
193
- (object = object(id)).get(response_target: tempfile.path)
234
+ tempfile = Tempfile.new(["shrine-s3", File.extname(id)], binmode: true)
235
+ (object = object(id)).get(response_target: tempfile)
194
236
  tempfile.singleton_class.instance_eval { attr_accessor :content_type }
195
237
  tempfile.content_type = object.content_type
196
238
  tempfile.tap(&:open)
@@ -214,18 +256,18 @@ class Shrine
214
256
  # This is called when multiple files are being deleted at once. Issues a
215
257
  # single MULTI DELETE command for each 1000 objects (S3 delete limit).
216
258
  def multi_delete(ids)
217
- objects = ids.take(1000).map { |id| {key: object(id).key} }
218
- bucket.delete_objects(delete: {objects: objects})
219
-
220
- rest = Array(ids[1000..-1])
221
- multi_delete(rest) unless rest.empty?
259
+ ids.each_slice(1000) do |ids_batch|
260
+ delete_params = {objects: ids_batch.map { |id| {key: object(id).key} }}
261
+ bucket.delete_objects(delete: delete_params)
262
+ end
222
263
  end
223
264
 
224
265
  # Returns the presigned URL to the file.
225
266
  #
226
267
  # :public
227
- # : Creates an unsigned version of the URL (the permissions on the S3
228
- # bucket need to be modified to allow public URLs).
268
+ # : Controls whether the URL is signed (`false`) or unsigned (`true`).
269
+ # Note that for unsigned URLs the S3 bucket need to be modified to allow
270
+ # public URLs. Defaults to `false`.
229
271
  #
230
272
  # :host
231
273
  # : This option replaces the host part of the returned URL, and is
@@ -279,10 +321,15 @@ class Shrine
279
321
  object(id).presigned_post(options)
280
322
  end
281
323
 
324
+ # Returns an `Aws::S3::Object` for the given id.
325
+ def object(id)
326
+ bucket.object([*prefix, id].join("/"))
327
+ end
328
+
282
329
  # Catches the deprecated `#stream` method.
283
330
  def method_missing(name, *args)
284
331
  if name == :stream
285
- warn "Shrine::Storage::S3#stream is deprecated over calling #each_chunk on S3#open."
332
+ Shrine.deprecation("Shrine::Storage::S3#stream is deprecated over calling #each_chunk on S3#open.")
286
333
  object = object(*args)
287
334
  object.get { |chunk| yield chunk, object.content_length }
288
335
  else
@@ -290,31 +337,28 @@ class Shrine
290
337
  end
291
338
  end
292
339
 
293
- protected
294
-
295
- # Returns the S3 object.
296
- def object(id)
297
- bucket.object([*prefix, id].join("/"))
298
- end
299
-
300
- # This is used to check whether an S3 file is copyable.
301
- def access_key_id
302
- s3.client.config.credentials.credentials.access_key_id
303
- end
304
-
305
340
  private
306
341
 
307
- # Copies an existing S3 object to a new location.
342
+ # Copies an existing S3 object to a new location. Uses multipart copy for
343
+ # large files.
308
344
  def copy(io, id, **options)
309
- options = {multipart_copy: true, content_length: io.size}.update(options) if multipart?(io)
345
+ # pass :content_length on multipart copy to avoid an additional HEAD request
346
+ options = {multipart_copy: true, content_length: io.size}.update(options) if io.size && io.size >= @multipart_threshold[:copy]
310
347
  object(id).copy_from(io.storage.object(io.id), **options)
311
348
  end
312
349
 
313
- # Uploads the file to S3.
350
+ # Uploads the file to S3. Uses multipart upload for large files.
314
351
  def put(io, id, **options)
315
352
  if io.respond_to?(:path)
316
- options = {multipart_threshold: @multipart_threshold}.update(options)
317
- object(id).upload_file(io.path, **options)
353
+ path = io.path
354
+ elsif io.is_a?(UploadedFile) && defined?(Storage::FileSystem) && io.storage.is_a?(Storage::FileSystem)
355
+ path = io.storage.path(io.id).to_s
356
+ end
357
+
358
+ if path
359
+ # use `upload_file` for files because it can do multipart upload
360
+ options = {multipart_threshold: @multipart_threshold[:upload]}.update(options)
361
+ object(id).upload_file(path, **options)
318
362
  else
319
363
  object(id).put(body: io, **options)
320
364
  end
@@ -324,15 +368,12 @@ class Shrine
324
368
  def copyable?(io)
325
369
  io.is_a?(UploadedFile) &&
326
370
  io.storage.is_a?(Storage::S3) &&
327
- io.storage.access_key_id == access_key_id
328
- end
329
-
330
- # Determines whether multipart upload/copy should be used from
331
- # `:multipart_threshold`.
332
- def multipart?(io)
333
- io.size && io.size >= @multipart_threshold
371
+ io.storage.client.config.access_key_id == client.config.access_key_id
334
372
  end
335
373
 
374
+ # Upload requests will fail if filename has non-ASCII characters, because
375
+ # of how S3 generates signatures, so we URI-encode them. Most browsers
376
+ # should automatically URI-decode filenames when downloading.
336
377
  def encode_content_disposition(content_disposition)
337
378
  content_disposition.sub(/(?<=filename=").+(?=")/) do |filename|
338
379
  CGI.escape(filename).sub("+", " ")