shrine 2.13.0 → 2.14.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +20 -16
  4. data/doc/creating_storages.md +0 -21
  5. data/doc/design.md +1 -0
  6. data/doc/direct_s3.md +26 -15
  7. data/doc/metadata.md +67 -22
  8. data/doc/multiple_files.md +3 -3
  9. data/doc/processing.md +1 -1
  10. data/doc/retrieving_uploads.md +184 -0
  11. data/lib/shrine.rb +268 -900
  12. data/lib/shrine/attacher.rb +271 -0
  13. data/lib/shrine/attachment.rb +97 -0
  14. data/lib/shrine/plugins.rb +29 -0
  15. data/lib/shrine/plugins/_urlsafe_serialization.rb +182 -0
  16. data/lib/shrine/plugins/activerecord.rb +16 -14
  17. data/lib/shrine/plugins/add_metadata.rb +58 -24
  18. data/lib/shrine/plugins/backgrounding.rb +6 -1
  19. data/lib/shrine/plugins/cached_attachment_data.rb +9 -9
  20. data/lib/shrine/plugins/copy.rb +12 -8
  21. data/lib/shrine/plugins/data_uri.rb +23 -20
  22. data/lib/shrine/plugins/default_url_options.rb +5 -4
  23. data/lib/shrine/plugins/determine_mime_type.rb +24 -23
  24. data/lib/shrine/plugins/download_endpoint.rb +61 -73
  25. data/lib/shrine/plugins/migration_helpers.rb +17 -17
  26. data/lib/shrine/plugins/module_include.rb +9 -8
  27. data/lib/shrine/plugins/presign_endpoint.rb +13 -7
  28. data/lib/shrine/plugins/processing.rb +1 -1
  29. data/lib/shrine/plugins/rack_response.rb +128 -36
  30. data/lib/shrine/plugins/refresh_metadata.rb +20 -5
  31. data/lib/shrine/plugins/remote_url.rb +8 -8
  32. data/lib/shrine/plugins/remove_attachment.rb +9 -9
  33. data/lib/shrine/plugins/sequel.rb +21 -18
  34. data/lib/shrine/plugins/tempfile.rb +68 -0
  35. data/lib/shrine/plugins/upload_endpoint.rb +3 -2
  36. data/lib/shrine/plugins/upload_options.rb +7 -6
  37. data/lib/shrine/plugins/validation_helpers.rb +2 -1
  38. data/lib/shrine/storage/file_system.rb +20 -17
  39. data/lib/shrine/storage/linter.rb +0 -7
  40. data/lib/shrine/storage/s3.rb +159 -50
  41. data/lib/shrine/uploaded_file.rb +258 -0
  42. data/lib/shrine/version.rb +1 -1
  43. data/shrine.gemspec +7 -19
  44. metadata +41 -21
@@ -65,27 +65,27 @@ class Shrine
65
65
 
66
66
  return if shrine_class.opts[:migration_helpers_delegate] == false
67
67
 
68
- module_eval <<-RUBY, __FILE__, __LINE__ + 1
69
- def update_#{name}(&block)
70
- #{name}_attacher.update_stored(&block)
71
- end
68
+ name = attachment_name
72
69
 
73
- def #{name}_cache
74
- #{name}_attacher.cache
75
- end
70
+ define_method :"update_#{name}" do |&block|
71
+ send(:"#{name}_attacher").update_stored(&block)
72
+ end
76
73
 
77
- def #{name}_store
78
- #{name}_attacher.store
79
- end
74
+ define_method :"#{name}_cache" do
75
+ send(:"#{name}_attacher").cache
76
+ end
80
77
 
81
- def #{name}_cached?
82
- #{name}_attacher.cached?
83
- end
78
+ define_method :"#{name}_store" do
79
+ send(:"#{name}_attacher").store
80
+ end
84
81
 
85
- def #{name}_stored?
86
- #{name}_attacher.stored?
87
- end
88
- RUBY
82
+ define_method :"#{name}_cached?" do
83
+ send(:"#{name}_attacher").cached?
84
+ end
85
+
86
+ define_method :"#{name}_stored?" do
87
+ send(:"#{name}_attacher").stored?
88
+ end
89
89
  end
90
90
  end
91
91
 
@@ -19,15 +19,16 @@ class Shrine
19
19
  # def included(model)
20
20
  # super
21
21
  #
22
- # module_eval <<-RUBY, __FILE__, __LINE__ + 1
23
- # def #{@name}_size(version)
24
- # if #{@name}.is_a?(Hash)
25
- # #{@name}[version].size
26
- # else
27
- # #{@name}.size if #{@name}
28
- # end
22
+ # name = attachment_name
23
+ #
24
+ # define_method :"#{name}_size" do |version|
25
+ # attachment = send(name)
26
+ # if attachment.is_a?(Hash)
27
+ # attachment[version].size
28
+ # elsif attachment
29
+ # attachment.size
29
30
  # end
30
- # RUBY
31
+ # end
31
32
  # end
32
33
  # end
33
34
  #
@@ -75,16 +75,20 @@ class Shrine
75
75
  # `:presign_options`, here is an example for S3 storage:
76
76
  #
77
77
  # plugin :presign_endpoint, presign_options: -> (request) do
78
- # filename = request.params["filename"]
79
- # type = request.params["type"]
78
+ # # Uppy will send the "filename" and "type" query parameters
79
+ # filename = request.params["filename"]
80
+ # type = request.params["type"]
80
81
  #
81
82
  # {
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
83
+ # content_length_range: 0..(10*1024*1024), # limit filesize to 10MB
84
+ # content_disposition: ContentDisposition.inline(filename), # download with original filename
85
+ # content_type: type, # set correct content type
85
86
  # }
86
87
  # end
87
88
  #
89
+ # The example above uses the [content_disposition] gem to correctly format
90
+ # the `Content-Disposition` header value.
91
+ #
88
92
  # The `:presign_options` can be a Proc or a Hash.
89
93
  #
90
94
  # ## Presign
@@ -116,6 +120,7 @@ class Shrine
116
120
  # [Amazon S3]: https://aws.amazon.com/s3/
117
121
  # [Google Cloud Storage]: https://cloud.google.com/storage/
118
122
  # [Microsoft Azure Storage]: https://azure.microsoft.com/en-us/services/storage/
123
+ # [content_disposition]: https://github.com/shrinerb/content_disposition
119
124
  module PresignEndpoint
120
125
  def self.configure(uploader, opts = {})
121
126
  uploader.opts[:presign_endpoint_presign_location] = opts.fetch(:presign_location, uploader.opts[:presign_endpoint_presign_location])
@@ -133,14 +138,15 @@ class Shrine
133
138
  # Additional options can be given to override the options given on
134
139
  # plugin initialization.
135
140
  def presign_endpoint(storage_key, **options)
136
- App.new({
141
+ App.new(
137
142
  shrine_class: self,
138
143
  storage_key: storage_key,
139
144
  presign_location: opts[:presign_endpoint_presign_location],
140
145
  presign_options: opts[:presign_endpoint_presign_options],
141
146
  presign: opts[:presign_endpoint_presign],
142
147
  rack_response: opts[:presign_endpoint_rack_response],
143
- }.merge(options))
148
+ **options
149
+ )
144
150
  end
145
151
  end
146
152
 
@@ -59,7 +59,7 @@ class Shrine
59
59
  # [image_processing]: https://github.com/janko-m/image_processing
60
60
  module Processing
61
61
  def self.configure(uploader)
62
- uploader.opts[:processing] = {}
62
+ uploader.opts[:processing] ||= {}
63
63
  end
64
64
 
65
65
  module ClassMethods
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rack"
4
+ require "content_disposition"
4
5
 
5
6
  class Shrine
6
7
  module Plugins
@@ -29,27 +30,52 @@ class Shrine
29
30
  # class FilesController < ActionController::Base
30
31
  # def download
31
32
  # # ...
32
- # file_status, file_headers, file_body = record.attachment.to_rack_response
33
+ # set_rack_response record.attachment.to_rack_response
34
+ # end
35
+ #
36
+ # private
33
37
  #
34
- # response.status = file_status
35
- # response.headers.merge!(file_headers)
36
- # self.response_body = file_body
38
+ # def set_rack_response((status, headers, body))
39
+ # self.status = status
40
+ # self.headers.merge!(headers)
41
+ # self.response_body = body
37
42
  # end
38
43
  # end
39
44
  #
45
+ # The `#each` method on the response body object will stream the uploaded
46
+ # file directly from the storage. It also works with [Rack::Sendfile] when
47
+ # using `FileSystem` storage.
48
+ #
49
+ # ## Type
50
+ #
51
+ # The response `Content-Type` header will default to the value of the
52
+ # `mime_type` metadata. A custom content type can be provided via the
53
+ # `:type` option:
54
+ #
55
+ # _, headers, _ uploaded_file.to_rack_response(type: "text/plain; charset=utf-8")
56
+ # headers["Content-Type"] #=> "text/plain; charset=utf-8"
57
+ #
58
+ # ## Filename
59
+ #
60
+ # The download filename in the `Content-Disposition` header will default to
61
+ # the value of the `filename` metadata. A custom download filename can be
62
+ # provided via the `:filename` option:
63
+ #
64
+ # _, headers, _ uploaded_file.to_rack_response(filename: "my-filename.txt")
65
+ # headers["Content-Disposition"] #=> "inline; filename=\"my-filename.txt\""
66
+ #
40
67
  # ## Disposition
41
68
  #
42
- # By default the "Content-Disposition" header will use the `inline`
43
- # disposition, but you can change it to `attachment` if you don't want the
44
- # file to be rendered inside the browser:
69
+ # The default disposition in the "Content-Disposition" header is `inline`,
70
+ # but it can be changed via the `:disposition` option:
45
71
  #
46
- # status, headers, body = uploaded_file.to_rack_response(disposition: "attachment")
72
+ # _, headers, _ = uploaded_file.to_rack_response(disposition: "attachment")
47
73
  # headers["Content-Disposition"] #=> "attachment; filename=\"file.txt\""
48
74
  #
49
75
  # ## Range
50
76
  #
51
77
  # [Partial responses][range requests] are also supported via the `:range`
52
- # parameter, which accepts a value of the `Range` request header.
78
+ # option, which accepts a value of the `Range` request header.
53
79
  #
54
80
  # env["HTTP_RANGE"] #=> "bytes=100-200"
55
81
  # status, headers, body = uploaded_file.to_rack_response(range: env["HTTP_RANGE"])
@@ -59,35 +85,55 @@ class Shrine
59
85
  # body # partial content
60
86
  #
61
87
  # [range requests]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
88
+ # [Rack::Sendfile]: https://www.rubydoc.info/github/rack/rack/Rack/Sendfile
62
89
  module RackResponse
63
90
  module FileMethods
64
91
  # Returns a Rack response triple for the uploaded file.
65
- def to_rack_response(disposition: "inline", range: false)
66
- range = parse_http_range(range) if range
92
+ def to_rack_response(**options)
93
+ FileResponse.new(self).call(**options)
94
+ end
95
+ end
96
+
97
+ class FileResponse
98
+ attr_reader :file
99
+
100
+ def initialize(file)
101
+ @file = file
102
+ end
103
+
104
+ # Returns a Rack response triple for the uploaded file.
105
+ def call(**options)
106
+ options[:range] = parse_content_range(options[:range]) if options[:range]
67
107
 
68
- status = range ? 206 : 200
69
- headers = rack_headers(disposition: disposition, range: range)
70
- body = rack_body(range: range)
108
+ status = rack_status(**options)
109
+ headers = rack_headers(**options)
110
+ body = rack_body(**options)
71
111
 
72
112
  [status, headers, body]
73
113
  end
74
114
 
75
115
  private
76
116
 
117
+ # Returns "200 OK" on full request, and "206 Partial Content" on ranged
118
+ # request.
119
+ def rack_status(range: nil, **)
120
+ range ? 206 : 200
121
+ end
122
+
77
123
  # Returns a hash of "Content-Length", "Content-Type" and
78
124
  # "Content-Disposition" headers, whose values are extracted from
79
125
  # metadata. Also returns the correct "Content-Range" header on ranged
80
126
  # requests.
81
- def rack_headers(disposition:, range: false)
82
- length = range ? range.size : size || io.size
83
- type = mime_type || Rack::Mime.mime_type(".#{extension}")
84
- filename = original_filename || id.split("/").last
127
+ def rack_headers(filename: nil, type: nil, disposition: "inline", range: false)
128
+ length = range ? range.size : size
129
+ type ||= @file.mime_type || Rack::Mime.mime_type(".#{@file.extension}")
130
+ filename ||= @file.original_filename || @file.id.split("/").last
85
131
 
86
132
  headers = {}
87
133
  headers["Content-Length"] = length.to_s if length
88
134
  headers["Content-Type"] = type
89
- headers["Content-Disposition"] = "#{disposition}; filename=\"#{filename}\""
90
- headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size||io.size}" if range
135
+ headers["Content-Disposition"] = content_disposition(disposition: disposition, filename: filename)
136
+ headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}" if range
91
137
  headers["Accept-Ranges"] = "bytes" unless range == false
92
138
 
93
139
  headers
@@ -95,19 +141,69 @@ class Shrine
95
141
 
96
142
  # Returns an object that responds to #each and #close, which yields
97
143
  # contents of the file.
98
- def rack_body(range: nil)
144
+ def rack_body(range: nil, **)
145
+ FileBody.new(file, range: range)
146
+ end
147
+
148
+ # Parses the value of a "Range" HTTP header.
149
+ def parse_content_range(range_header)
150
+ if Rack.release >= "2.0"
151
+ ranges = Rack::Utils.get_byte_ranges(range_header, size)
152
+ else
153
+ ranges = Rack::Utils.byte_ranges({"HTTP_RANGE" => range_header}, size)
154
+ end
155
+
156
+ ranges.first if ranges && ranges.one?
157
+ end
158
+
159
+ def content_disposition(disposition:, filename:)
160
+ ContentDisposition.format(disposition: disposition, filename: filename)
161
+ end
162
+
163
+ # Read size from metadata, otherwise retrieve the size from the storage.
164
+ def size
165
+ @file.size || @file.to_io.size
166
+ end
167
+ end
168
+
169
+ # Implements the interface of a Rack response body object.
170
+ class FileBody
171
+ attr_reader :file, :range
172
+
173
+ def initialize(file, range: nil)
174
+ @file = file
175
+ @range = range
176
+ end
177
+
178
+ # Streams the uploaded file directly from the storage.
179
+ def each(&block)
99
180
  if range
100
- body = enum_for(:read_partial_chunks, range)
181
+ read_partial_chunks(&block)
101
182
  else
102
- body = enum_for(:read_chunks)
183
+ read_chunks(&block)
103
184
  end
185
+ end
186
+
187
+ # Closes the file when response body is closed by the web server.
188
+ def close
189
+ file.close
190
+ end
104
191
 
105
- Rack::BodyProxy.new(body) { io.close }
192
+ # Rack::Sendfile is activated when response body responds to #to_path.
193
+ def respond_to_missing?(name, include_private = false)
194
+ name == :to_path && path
106
195
  end
107
196
 
197
+ # Rack::Sendfile is activated when response body responds to #to_path.
198
+ def method_missing(name, *args, &block)
199
+ name == :to_path && path or super
200
+ end
201
+
202
+ private
203
+
108
204
  # Yields reasonably sized chunks of uploaded file's partial content
109
205
  # specified by the given index range.
110
- def read_partial_chunks(range)
206
+ def read_partial_chunks
111
207
  bytes_read = 0
112
208
 
113
209
  read_chunks do |chunk|
@@ -130,22 +226,18 @@ class Shrine
130
226
 
131
227
  # Yields reasonably sized chunks of uploaded file's content.
132
228
  def read_chunks
133
- if io.respond_to?(:each_chunk) # Down::ChunkedIO
134
- io.each_chunk { |chunk| yield chunk }
229
+ if file.to_io.respond_to?(:each_chunk) # Down::ChunkedIO
230
+ file.to_io.each_chunk { |chunk| yield chunk }
135
231
  else
136
- yield io.read(16*1024) until io.eof?
232
+ yield file.read(16*1024) until file.eof?
137
233
  end
138
234
  end
139
235
 
140
- # Parses the value of a "Range" HTTP header.
141
- def parse_http_range(range_header)
142
- if Rack.release >= "2.0"
143
- ranges = Rack::Utils.get_byte_ranges(range_header, size || io.size)
144
- else
145
- ranges = Rack::Utils.byte_ranges({"HTTP_RANGE" => range_header}, size || io.size)
236
+ # Returns actual path on disk when FileSystem storage is used.
237
+ def path
238
+ if defined?(Storage::FileSystem) && file.storage.is_a?(Storage::FileSystem)
239
+ file.storage.path(file.id)
146
240
  end
147
-
148
- ranges.first if ranges && ranges.one?
149
241
  end
150
242
  end
151
243
  end
@@ -7,9 +7,10 @@ class Shrine
7
7
  #
8
8
  # plugin :refresh_metadata
9
9
  #
10
- # It provides `UploadedFile#refresh_metadata!` method, which calls
11
- # `Shrine#extract_metadata` with the uploaded file opened for reading,
12
- # and updates the existing metadata hash with the results.
10
+ # It provides `UploadedFile#refresh_metadata!` method, which triggers
11
+ # metadata extraction (calls `Shrine#extract_metadata`) with the uploaded
12
+ # file opened for reading, and updates the existing metadata hash with the
13
+ # results.
13
14
  #
14
15
  # uploaded_file.refresh_metadata!
15
16
  # uploaded_file.metadata # re-extracted metadata
@@ -17,11 +18,25 @@ class Shrine
17
18
  # For remote storages this will make an HTTP request to open the file for
18
19
  # reading, but only the portion of the file needed for extracting each
19
20
  # metadata value will be downloaded.
21
+ #
22
+ # If the uploaded file is already open, it is passed to metadata extraction
23
+ # as is.
24
+ #
25
+ # uploaded_file.open do
26
+ # uploaded_file.refresh_metadata!
27
+ # # ...
28
+ # end
20
29
  module RefreshMetadata
21
30
  module FileMethods
22
31
  def refresh_metadata!(context = {})
23
- refreshed_metadata = open { uploader.extract_metadata(self, context) }
24
- metadata.merge!(refreshed_metadata)
32
+ refreshed_metadata =
33
+ if @io
34
+ uploader.extract_metadata(self, context)
35
+ else
36
+ open { uploader.extract_metadata(self, context) }
37
+ end
38
+
39
+ @data = @data.merge("metadata" => metadata.merge(refreshed_metadata))
25
40
  end
26
41
  end
27
42
  end
@@ -102,15 +102,15 @@ class Shrine
102
102
  def initialize(*)
103
103
  super
104
104
 
105
- module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
- def #{@name}_remote_url=(url)
107
- #{@name}_attacher.remote_url = url
108
- end
105
+ name = attachment_name
109
106
 
110
- def #{@name}_remote_url
111
- #{@name}_attacher.remote_url
112
- end
113
- RUBY
107
+ define_method :"#{name}_remote_url=" do |url|
108
+ send(:"#{name}_attacher").remote_url = url
109
+ end
110
+
111
+ define_method :"#{name}_remote_url" do
112
+ send(:"#{name}_attacher").remote_url
113
+ end
114
114
  end
115
115
  end
116
116
 
@@ -17,15 +17,15 @@ class Shrine
17
17
  def initialize(*)
18
18
  super
19
19
 
20
- module_eval <<-RUBY, __FILE__, __LINE__ + 1
21
- def remove_#{@name}=(value)
22
- #{@name}_attacher.remove = value
23
- end
24
-
25
- def remove_#{@name}
26
- #{@name}_attacher.remove
27
- end
28
- RUBY
20
+ name = attachment_name
21
+
22
+ define_method :"remove_#{name}=" do |value|
23
+ send(:"#{name}_attacher").remove = value
24
+ end
25
+
26
+ define_method :"remove_#{name}" do
27
+ send(:"#{name}_attacher").remove
28
+ end
29
29
  end
30
30
  end
31
31