shrine 2.12.0 → 2.13.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.

@@ -179,9 +179,14 @@ class Shrine
179
179
 
180
180
  raise Error, "file command failed to spawn: #{stderr.read}" if status.nil?
181
181
  raise Error, "file command failed: #{stderr.read}" unless status.success?
182
+
182
183
  $stderr.print(stderr.read)
183
184
 
184
- stdout.read.strip
185
+ output = stdout.read.strip
186
+
187
+ raise Error, "file command failed: #{output}" if output.include?("cannot open")
188
+
189
+ output
185
190
  end
186
191
  rescue Errno::ENOENT
187
192
  raise Error, "file command-line tool is not installed"
@@ -12,6 +12,9 @@ class Shrine
12
12
  # from your storage isn't accessible over URL (e.g. database storages) or
13
13
  # if you want to authenticate your downloads. It requires the [Roda] gem.
14
14
  #
15
+ # # Gemfile
16
+ # gem "roda" # dependency of the download_endpoint plugin
17
+ #
15
18
  # You can configure the plugin with the path prefix which the endpoint will
16
19
  # be mounted on.
17
20
  #
@@ -5,21 +5,29 @@ class Shrine
5
5
  # The `infer_extension` plugin allows deducing the appropriate file
6
6
  # extension for the upload location based on the MIME type of the file.
7
7
  # This is useful when using `data_uri` and `remote_url` plugins, where the
8
- # file extension is not or might not be known.
8
+ # file extension might not be known.
9
9
  #
10
10
  # plugin :infer_extension
11
11
  #
12
- # The upload location will gain the inferred extension only if couldn't be
13
- # determined from the filename. By default `MIME::Types` will be used for
14
- # inferring the extension, but you can also choose a different inferrer:
12
+ # Ordinarily, the upload location will gain the inferred extension only if
13
+ # it couldn't be determined from the filename. However, you can pass
14
+ # `force: true` to force the inferred extension to be used rather than an
15
+ # extension from the original filename. This can be used to canonicalize
16
+ # extensions (jpg, jpeg => jpeg), or replace an incorrect original
17
+ # extension.
18
+ #
19
+ # plugin :infer_extension, force: true
20
+ #
21
+ # By default `MIME::Types` will be used for inferring the extension, but
22
+ # you can also choose a different inferrer:
15
23
  #
16
24
  # plugin :infer_extension, inferrer: :mini_mime
17
25
  #
18
26
  # The following inferrers are accepted:
19
27
  #
20
28
  # :mime_types
21
- # : (Default). Uses the [mime-types] gem to infer the appropriate extension from MIME
22
- # type.
29
+ # : (Default). Uses the [mime-types] gem to infer the appropriate extension
30
+ # from MIME type.
23
31
  #
24
32
  # :mini_mime
25
33
  # : Uses the [mini_mime] gem to infer the appropriate extension from MIME
@@ -45,12 +53,13 @@ class Shrine
45
53
  # [mini_mime]: https://github.com/discourse/mini_mime
46
54
  module InferExtension
47
55
  def self.configure(uploader, opts = {})
48
- uploader.opts[:extension_inferrer] = opts.fetch(:inferrer, uploader.opts.fetch(:infer_extension_inferrer, :mime_types))
56
+ uploader.opts[:infer_extension_inferrer] = opts.fetch(:inferrer, uploader.opts.fetch(:infer_extension_inferrer, :mime_types))
57
+ uploader.opts[:infer_extension_force] = opts.fetch(:force, uploader.opts.fetch(:infer_extension_force, false))
49
58
  end
50
59
 
51
60
  module ClassMethods
52
61
  def infer_extension(mime_type)
53
- inferrer = opts[:extension_inferrer]
62
+ inferrer = opts[:infer_extension_inferrer]
54
63
  inferrer = extension_inferrer(inferrer) if inferrer.is_a?(Symbol)
55
64
  args = [mime_type, extension_inferrers].take(inferrer.arity.abs)
56
65
 
@@ -72,8 +81,14 @@ class Shrine
72
81
  def generate_location(io, context = {})
73
82
  mime_type = (context[:metadata] || {})["mime_type"]
74
83
 
75
- location = super
76
- location += infer_extension(mime_type) if File.extname(location).empty?
84
+ location = super
85
+ current_extension = File.extname(location)
86
+
87
+ if current_extension.empty? || opts[:infer_extension_force]
88
+ inferred_extension = infer_extension(mime_type)
89
+ location = location.chomp(current_extension) << inferred_extension unless inferred_extension.empty?
90
+ end
91
+
77
92
  location
78
93
  end
79
94
 
@@ -19,7 +19,7 @@ class Shrine
19
19
  # def included(model)
20
20
  # super
21
21
  #
22
- # module_eval <<-RUBY, __FILE__, __LINE + 1
22
+ # module_eval <<-RUBY, __FILE__, __LINE__ + 1
23
23
  # def #{@name}_size(version)
24
24
  # if #{@name}.is_a?(Hash)
25
25
  # #{@name}[version].size
@@ -72,17 +72,16 @@ class Shrine
72
72
  # ## Options
73
73
  #
74
74
  # Some storages accept additional presign options, which you can pass in via
75
- # `:presign_options`:
75
+ # `:presign_options`, here is an example for S3 storage:
76
76
  #
77
77
  # plugin :presign_endpoint, presign_options: -> (request) do
78
78
  # filename = request.params["filename"]
79
- # extension = File.extname(filename)
80
- # content_type = Rack::Mime.mime_type(extension)
79
+ # type = request.params["type"]
81
80
  #
82
81
  # {
83
- # content_length_range: 0..(10*1024*1024), # limit filesize to 10MB
84
- # content_disposition: "attachment; filename=\"#{filename}\"", # download with original filename
85
- # content_type: content_type, # set correct content type
82
+ # content_length_range: 0..(10*1024*1024), # limit filesize to 10MB
83
+ # content_disposition: "inline; filename=\"#{filename}\"", # download with original filename
84
+ # content_type: type, # set correct content type
86
85
  # }
87
86
  # end
88
87
  #
@@ -149,6 +148,9 @@ class Shrine
149
148
  # `#presign` on the specified storage, and returns that information in
150
149
  # JSON format.
151
150
  class App
151
+ CONTENT_TYPE_JSON = "application/json; charset=utf-8"
152
+ CONTENT_TYPE_TEXT = "text/plain"
153
+
152
154
  # Writes given options to instance variables.
153
155
  def initialize(options)
154
156
  options.each do |name, value|
@@ -240,7 +242,7 @@ class Shrine
240
242
  if @rack_response
241
243
  response = @rack_response.call(object, request)
242
244
  else
243
- response = [200, {"Content-Type" => "application/json"}, [object.to_json]]
245
+ response = [200, {"Content-Type" => CONTENT_TYPE_JSON}, [object.to_json]]
244
246
  end
245
247
 
246
248
  # prevent browsers from caching the response
@@ -251,7 +253,7 @@ class Shrine
251
253
 
252
254
  # Used for early returning an error response.
253
255
  def error!(status, message)
254
- throw :halt, [status, {"Content-Type" => "text/plain"}, [message]]
256
+ throw :halt, [status, {"Content-Type" => CONTENT_TYPE_TEXT}, [message]]
255
257
  end
256
258
 
257
259
  # Returns the uploader around the specified storage.
@@ -47,6 +47,14 @@ class Shrine
47
47
  module ClassMethods
48
48
  # Accepts a Rack uploaded file hash and wraps it in an IO object.
49
49
  def rack_file(hash)
50
+ if hash[:filename]
51
+ # Rack can sometimes return the filename binary encoded, so we force
52
+ # the encoding to utf-8
53
+ hash = hash.merge(
54
+ filename: hash[:filename].dup.force_encoding(Encoding::UTF_8)
55
+ )
56
+ end
57
+
50
58
  UploadedFile.new(hash)
51
59
  end
52
60
  end
@@ -64,7 +64,7 @@ class Shrine
64
64
  #
65
65
  # [fastimage]: https://github.com/sdsykes/fastimage
66
66
  # [mini_magick]: https://github.com/minimagick/minimagick
67
- # [ruby-vips]: https://github.com/jcupitt/ruby-vips
67
+ # [ruby-vips]: https://github.com/libvips/ruby-vips
68
68
  module StoreDimensions
69
69
  def self.configure(uploader, opts = {})
70
70
  uploader.opts[:dimensions_analyzer] = opts.fetch(:analyzer, uploader.opts.fetch(:dimensions_analyzer, :fastimage))
@@ -94,7 +94,7 @@ class Shrine
94
94
  # You can also customize the upload itself via the `:upload` option:
95
95
  #
96
96
  # plugin :upload_endpoint, upload: -> (io, context, request) do
97
- # # perform uploading and return the Shrine::UploadedFile
97
+ # Shrine.new(:cache).upload(io, context)
98
98
  # end
99
99
  #
100
100
  # ## Response
@@ -150,6 +150,9 @@ class Shrine
150
150
  # calls `#upload` with the uploaded file, and returns the uploaded file
151
151
  # information in JSON format.
152
152
  class App
153
+ CONTENT_TYPE_JSON = "application/json; charset=utf-8"
154
+ CONTENT_TYPE_TEXT = "text/plain"
155
+
153
156
  # Writes given options to instance variables.
154
157
  def initialize(options)
155
158
  options.each do |name, value|
@@ -196,7 +199,8 @@ class Shrine
196
199
  def get_io(request)
197
200
  file = request.params["file"]
198
201
 
199
- error!(400, "Upload Not Found") unless file.is_a?(Hash) && file[:tempfile]
202
+ error!(400, "Upload Not Found") if file.nil?
203
+ error!(400, "Upload Not Valid") unless file.is_a?(Hash) && file[:tempfile]
200
204
  error!(413, "Upload Too Large") if @max_size && file[:tempfile].size > @max_size
201
205
 
202
206
  verify_checksum!(file[:tempfile], request.env["HTTP_CONTENT_MD5"]) if request.env["HTTP_CONTENT_MD5"]
@@ -232,7 +236,7 @@ class Shrine
232
236
  if @rack_response
233
237
  @rack_response.call(object, request)
234
238
  else
235
- [200, {"Content-Type" => "application/json"}, [object.to_json]]
239
+ [200, {"Content-Type" => CONTENT_TYPE_JSON}, [object.to_json]]
236
240
  end
237
241
  end
238
242
 
@@ -246,7 +250,7 @@ class Shrine
246
250
 
247
251
  # Used for early returning an error response.
248
252
  def error!(status, message)
249
- throw :halt, [status, {"Content-Type" => "text/plain"}, [message]]
253
+ throw :halt, [status, {"Content-Type" => CONTENT_TYPE_TEXT}, [message]]
250
254
  end
251
255
 
252
256
  # Returns the uploader around the specified storage.
@@ -234,8 +234,7 @@ class Shrine
234
234
  end
235
235
  end
236
236
 
237
- # Deletes each file individually, but uses S3's multi delete
238
- # capabilities.
237
+ # Deletes each file individually
239
238
  def _delete(uploaded_file, context)
240
239
  if (hash = uploaded_file).is_a?(Hash)
241
240
  hash.each do |name, value|
@@ -165,8 +165,9 @@ class Shrine
165
165
  # Delets the file, and by default deletes the containing directory if
166
166
  # it's empty.
167
167
  def delete(id)
168
- path(id).delete
169
- clean(path(id)) if clean?
168
+ path = path(id)
169
+ path.delete
170
+ clean(path) if clean?
170
171
  rescue Errno::ENOENT
171
172
  end
172
173
 
@@ -241,8 +242,9 @@ class Shrine
241
242
 
242
243
  # Creates all intermediate directories for that location.
243
244
  def path!(id)
244
- FileUtils.mkdir_p(path(id).dirname, mode: directory_permissions)
245
- path(id)
245
+ path = path(id)
246
+ FileUtils.mkdir_p(path.dirname, mode: directory_permissions)
247
+ path
246
248
  end
247
249
 
248
250
  def relative_path(id)
@@ -37,7 +37,7 @@ class Shrine
37
37
  # region: "eu-west-1",
38
38
  # )
39
39
  #
40
- # The core features of this storage requires the following AWS permissions:
40
+ # The core features of this storage require the following AWS permissions:
41
41
  # `s3:ListBucket`, `s3:PutObject`, `s3:GetObject`, and `s3:DeleteObject`.
42
42
  # If you have additional upload options configured such as setting object
43
43
  # ACLs, then additional permissions may be required.
@@ -54,6 +54,18 @@ class Shrine
54
54
  #
55
55
  # s3.object("key") #=> #<Aws::S3::Object>
56
56
  #
57
+ # ## Public uploads
58
+ #
59
+ # By default, uploaded S3 objects will have private visibility, meaning
60
+ # they can only be accessed via signed expiring URLs generated using your
61
+ # private S3 credentials. If you would like to generate public URLs, you
62
+ # can tell S3 storage to make uploads public:
63
+ #
64
+ # s3 = Shrine::Storage::S3.new(public: true, **s3_options)
65
+ #
66
+ # s3.upload(io, "key") # uploads with "public-read" ACL
67
+ # s3.url("key") # returns public (unsigned) object URL
68
+ #
57
69
  # ## Prefix
58
70
  #
59
71
  # The `:prefix` option can be specified for uploading all files inside
@@ -103,22 +115,44 @@ class Shrine
103
115
  #
104
116
  # All other options are forwarded to the aws-sdk-s3 gem:
105
117
  #
106
- # s3.url(expires_in: 15)
107
- # s3.url(virtual_host: true)
118
+ # s3.url(expires_in: 15, response_content_disposition: "...")
108
119
  #
109
- # ## CDN
120
+ # ## URL Host
110
121
  #
111
- # If you're using a CDN with S3 like Amazon CloudFront, you can specify
112
- # the `:host` option to `#url`:
122
+ # If you want your S3 object URLs to be generated with a different URL host
123
+ # (e.g. a CDN), you can specify the `:host` option to `#url`:
113
124
  #
114
125
  # s3.url("image.jpg", host: "http://abc123.cloudfront.net")
115
126
  # #=> "http://abc123.cloudfront.net/image.jpg"
116
127
  #
117
- # You have the `:host` option passed automatically for every URL with the
128
+ # The host URL can include a path prefix, but it needs to end with a slash:
129
+ #
130
+ # s3.url("image.jpg", host: "https://your-s3-host.com/prefix/") # needs to end with a slash
131
+ # #=> "http://your-s3-host.com/prefix/image.jpg"
132
+ #
133
+ # To have the `:host` option passed automatically for every URL, use the
118
134
  # `default_url_options` plugin.
119
135
  #
120
136
  # plugin :default_url_options, store: { host: "http://abc123.cloudfront.net" }
121
137
  #
138
+ # If you would like to [serve private content via CloudFront], you need to
139
+ # sign the object URLs with a special signer, such as
140
+ # [`Aws::CloudFront::UrlSigner`] provided by the `aws-sdk-cloudfront` gem.
141
+ # The S3 storage initializer accepts a `:signer` block, which you can use
142
+ # to call your signer:
143
+ #
144
+ #
145
+ # require "aws-sdk-cloudfront"
146
+ #
147
+ # signer = Aws::CloudFront::UrlSigner.new(
148
+ # key_pair_id: "cf-keypair-id",
149
+ # private_key_path: "./cf_private_key.pem"
150
+ # )
151
+ #
152
+ # Shrine::Storage::S3.new(signer: signer.method(:signed_url))
153
+ # # or
154
+ # Shrine::Storage::S3.new(signer: -> (url, **options) { signer.signed_url(url, **options) })
155
+ #
122
156
  # ## Accelerate endpoint
123
157
  #
124
158
  # To use Amazon S3's [Transfer Acceleration] feature, you can change the
@@ -177,10 +211,12 @@ class Shrine
177
211
  # [aws-sdk-s3]: https://github.com/aws/aws-sdk-ruby/tree/master/gems/aws-sdk-s3
178
212
  # [Transfer Acceleration]: http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html
179
213
  # [object lifecycle]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#put-instance_method
214
+ # [serve private content via CloudFront]: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PrivateContent.html
215
+ # [`Aws::CloudFront::UrlSigner`]: https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/CloudFront/UrlSigner.html
180
216
  class S3
181
217
  MIN_PART_SIZE = 5 * 1024 * 1024 # 5MB
182
218
 
183
- attr_reader :client, :bucket, :prefix, :host, :upload_options
219
+ attr_reader :client, :bucket, :prefix, :host, :upload_options, :signer, :public
184
220
 
185
221
  # Initializes a storage for uploading to S3. All options are forwarded to
186
222
  # [`Aws::S3::Client#initialize`], except the following:
@@ -213,7 +249,7 @@ class Shrine
213
249
  # [`Aws::S3::Bucket#presigned_post`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_post-instance_method
214
250
  # [`Aws::S3::Client#initialize`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#initialize-instance_method
215
251
  # [configuring AWS SDK]: https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/setup-config.html
216
- def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold: {}, **s3_options)
252
+ def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold: {}, signer: nil, public: nil, **s3_options)
217
253
  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
218
254
  resource = Aws::S3::Resource.new(**s3_options)
219
255
 
@@ -229,6 +265,8 @@ class Shrine
229
265
  @host = host
230
266
  @upload_options = upload_options
231
267
  @multipart_threshold = multipart_threshold
268
+ @signer = signer
269
+ @public = public
232
270
  end
233
271
 
234
272
  # Returns an `Aws::S3::Resource` object.
@@ -250,9 +288,10 @@ class Shrine
250
288
  options = {}
251
289
  options[:content_type] = content_type if content_type
252
290
  options[:content_disposition] = "inline; filename=\"#{filename}\"" if filename
291
+ options[:acl] = "public-read" if public
253
292
 
254
- options.update(@upload_options)
255
- options.update(upload_options)
293
+ options.merge!(@upload_options)
294
+ options.merge!(upload_options)
256
295
 
257
296
  options[:content_disposition] = encode_content_disposition(options[:content_disposition]) if options[:content_disposition]
258
297
 
@@ -306,11 +345,6 @@ class Shrine
306
345
 
307
346
  # Returns the presigned URL to the file.
308
347
  #
309
- # :public
310
- # : Controls whether the URL is signed (`false`) or unsigned (`true`).
311
- # Note that for unsigned URLs the S3 bucket need to be modified to allow
312
- # public URLs. Defaults to `false`.
313
- #
314
348
  # :host
315
349
  # : This option replaces the host part of the returned URL, and is
316
350
  # typically useful for setting CDN hosts (e.g.
@@ -326,11 +360,11 @@ class Shrine
326
360
  #
327
361
  # [`Aws::S3::Object#presigned_url`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_url-instance_method
328
362
  # [`Aws::S3::Object#public_url`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#public_url-instance_method
329
- def url(id, download: nil, public: nil, host: self.host, **options)
363
+ def url(id, download: nil, public: self.public, host: self.host, **options)
330
364
  options[:response_content_disposition] ||= "attachment" if download
331
365
  options[:response_content_disposition] = encode_content_disposition(options[:response_content_disposition]) if options[:response_content_disposition]
332
366
 
333
- if public
367
+ if public || signer
334
368
  url = object(id).public_url(**options)
335
369
  else
336
370
  url = object(id).presigned_url(:get, **options)
@@ -338,8 +372,12 @@ class Shrine
338
372
 
339
373
  if host
340
374
  uri = URI.parse(url)
341
- uri.path = uri.path.match(/^\/#{bucket.name}/).post_match unless uri.host.include?(bucket.name) || client.config.force_path_style
342
- url = URI.join(host, uri.request_uri).to_s
375
+ uri.path = uri.path.match(/^\/#{bucket.name}/).post_match unless uri.host.include?(bucket.name)
376
+ url = URI.join(host, uri.request_uri[1..-1]).to_s
377
+ end
378
+
379
+ if signer
380
+ url = signer.call(url, **options)
343
381
  end
344
382
 
345
383
  url
@@ -353,8 +391,13 @@ class Shrine
353
391
  #
354
392
  # [`Aws::S3::Object#presigned_post`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_post-instance_method
355
393
  # [`Aws::S3::Object#presigned_url`]: https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_url-instance_method
356
- def presign(id, method: :post, **options)
357
- options = @upload_options.merge(options)
394
+ def presign(id, method: :post, **presign_options)
395
+ options = {}
396
+ options[:acl] = "public-read" if public
397
+
398
+ options.merge!(@upload_options)
399
+ options.merge!(presign_options)
400
+
358
401
  options[:content_disposition] = encode_content_disposition(options[:content_disposition]) if options[:content_disposition]
359
402
 
360
403
  if method == :post
@@ -427,33 +470,43 @@ class Shrine
427
470
 
428
471
  # Uploads the file to S3. Uses multipart upload for large files.
429
472
  def put(io, id, **options)
430
- if io.respond_to?(:path)
431
- path = io.path
432
- elsif io.is_a?(UploadedFile) && defined?(Storage::FileSystem) && io.storage.is_a?(Storage::FileSystem)
433
- path = io.storage.path(io.id).to_s
434
- end
473
+ bytes_uploaded = nil
435
474
 
436
- if path
475
+ if (path = extract_path(io))
437
476
  # use `upload_file` for files because it can do multipart upload
438
477
  options = { multipart_threshold: @multipart_threshold[:upload] }.merge!(options)
439
478
  object(id).upload_file(path, **options)
440
- File.size(path)
479
+ bytes_uploaded = File.size(path)
441
480
  else
442
481
  io.to_io if io.is_a?(UploadedFile) # open if not already opened
443
482
 
444
- if io.respond_to?(:size) && io.size
483
+ if io.respond_to?(:size) && io.size && (io.size <= @multipart_threshold[:upload] || !object(id).respond_to?(:upload_stream))
445
484
  object(id).put(body: io, **options)
446
- io.size
485
+ bytes_uploaded = io.size
486
+ elsif object(id).respond_to?(:upload_stream)
487
+ # `upload_stream` uses multipart upload
488
+ object(id).upload_stream(tempfile: true, **options) do |write_stream|
489
+ bytes_uploaded = IO.copy_stream(io, write_stream)
490
+ end
447
491
  else
448
- # IO has unknown size, so we have to use multipart upload
449
- multipart_put(io, id, **options)
492
+ Shrine.deprecation "Uploading a file of unknown size with aws-sdk-s3 older than 1.14 is deprecated and will be removed in Shrine 3. Update to aws-sdk-s3 1.14 or higher."
493
+
494
+ Tempfile.create("shrine-s3", binmode: true) do |file|
495
+ bytes_uploaded = IO.copy_stream(io, file.path)
496
+ object(id).upload_file(file.path, **options)
497
+ end
450
498
  end
451
499
  end
500
+
501
+ bytes_uploaded
452
502
  end
453
503
 
454
- # Uploads the file to S3 using multipart upload.
455
- def multipart_put(io, id, **options)
456
- MultipartUploader.new(object(id)).upload(io, **options)
504
+ def extract_path(io)
505
+ if io.respond_to?(:path)
506
+ io.path
507
+ elsif io.is_a?(UploadedFile) && defined?(Storage::FileSystem) && io.storage.is_a?(Storage::FileSystem)
508
+ io.storage.path(io.id).to_s
509
+ end
457
510
  end
458
511
 
459
512
  # The file is copyable if it's on S3 and on the same Amazon account.
@@ -480,52 +533,6 @@ class Shrine
480
533
  CGI.escape(filename).gsub("+", " ")
481
534
  end
482
535
  end
483
-
484
- # Uploads IO objects of unknown size using the multipart API.
485
- class MultipartUploader
486
- def initialize(object)
487
- @object = object
488
- end
489
-
490
- # Initiates multipart upload, uploads IO content into multiple parts,
491
- # and completes the multipart upload. If an exception is raised, the
492
- # multipart upload is automatically aborted.
493
- def upload(io, **options)
494
- multipart_upload = @object.initiate_multipart_upload(**options)
495
-
496
- parts = upload_parts(multipart_upload, io)
497
- bytes_uploaded = parts.inject(0) { |size, part| size + part.delete(:size) }
498
-
499
- multipart_upload.complete(multipart_upload: { parts: parts })
500
-
501
- bytes_uploaded
502
- rescue
503
- multipart_upload.abort if multipart_upload
504
- raise
505
- end
506
-
507
- # Uploads parts until the IO object has reached EOF.
508
- def upload_parts(multipart_upload, io)
509
- 1.step.inject([]) do |parts, part_number|
510
- parts << upload_part(multipart_upload, io, part_number)
511
- break parts if io.eof?
512
- parts
513
- end
514
- end
515
-
516
- # Uploads at most 5MB of IO content into a single multipart part.
517
- def upload_part(multipart_upload, io, part_number)
518
- Tempfile.create("shrine-s3-part-#{part_number}", binmode: true) do |body|
519
- IO.copy_stream(io, body, MIN_PART_SIZE)
520
- body.rewind
521
-
522
- multipart_part = multipart_upload.part(part_number)
523
- response = multipart_part.upload(body: body)
524
-
525
- { part_number: part_number, size: body.size, etag: response.etag }
526
- end
527
- end
528
- end
529
536
  end
530
537
  end
531
538
  end