shrine 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,9 @@ require "json"
3
3
 
4
4
  class Shrine
5
5
  module Plugins
6
+ # *[OBSOLETE] This plugin is obsolete, you should use `upload_endpoint` or
7
+ # `presign_endpoint` plugins instead.*
8
+ #
6
9
  # The `direct_upload` plugin provides a Rack endpoint which can be used for
7
10
  # uploading individual files asynchronously. It requires the [Roda] gem.
8
11
  #
@@ -283,7 +286,7 @@ class Shrine
283
286
 
284
287
  # Generates a presign that points to the direct upload endpoint.
285
288
  def generate_fake_presign(location, options)
286
- url = request.url.sub(/presign$/, "upload")
289
+ url = request.url.sub(/presign[^\/]*$/, "upload")
287
290
  {url: url, fields: {key: location}}
288
291
  end
289
292
 
@@ -1,5 +1,7 @@
1
1
  require "roda"
2
- require "down"
2
+
3
+ require "base64"
4
+ require "json"
3
5
 
4
6
  class Shrine
5
7
  module Plugins
@@ -10,23 +12,23 @@ class Shrine
10
12
  #
11
13
  # plugin :download_endpoint, storages: [:store], prefix: "attachments"
12
14
  #
13
- # After loading the plugin the endpoint can be mounted:
15
+ # After loading the plugin the endpoint should be mounted on the specified
16
+ # prefix:
14
17
  #
15
18
  # Rails.appliations.routes.draw do
16
- # mount Shrine::DownloadEndpoint => "/attachments"
19
+ # mount Shrine.download_endpoint => "/attachments"
17
20
  # end
18
21
  #
19
22
  # Now all stored files can be downloaded through the endpoint, and the
20
23
  # endpoint will efficiently stream the file from the storage when the
21
24
  # storage supports it. `UploadedFile#url` will automatically return the URL
22
- # to the endpoint for specified storages, so it's not needed to change the
23
- # code:
25
+ # to the endpoint for files uploaded to specified storages:
24
26
  #
25
- # user.avatar.url #=> "/attachments/store/sdg0lsf8.jpg"
27
+ # user.avatar.url #=> "/attachments/eyJpZCI6ImFkdzlyeTM5ODJpandoYWla"
26
28
  #
27
29
  # :storages
28
- # : An array of storage keys which the download endpoint should be applied
29
- # on.
30
+ # : An array of storage keys for which `UploadedFile#url` should generate
31
+ # download endpoint URLs.
30
32
  #
31
33
  # :prefix
32
34
  # : The location where the download endpoint was mounted. If it was
@@ -42,22 +44,20 @@ class Shrine
42
44
  # The default is "inline".
43
45
  #
44
46
  # Note that streaming the file through your app might impact the request
45
- # throughput of your app, because on most popular web servers (Puma,
46
- # Unicorn, Passenger) workers handling this endpoint will not be able to
47
- # serve new requests until the client has fully downloaded the response
48
- # body.
47
+ # throughput of your app, depending on which web server is used. In any
48
+ # case, it's recommended to use some kind of cache in front of the web
49
+ # server.
49
50
  #
50
- # To prevent download endpoint from impacting your request throughput, use
51
- # a web server that handles streaming responses and slow clients well, like
52
- # [Thin], [Rainbows] or any other [EventMachine]-based web server that
53
- # implements `async.callback`.
51
+ # If you want to authenticate the downloads, it's recommended you use the
52
+ # `rack_response` plugin directly. With it you can return file responses
53
+ # from inside your router/controller.
54
54
  #
55
55
  # [Roda]: https://github.com/jeremyevans/roda
56
- # [Thin]: https://github.com/macournoyer/thin
57
- # [Rainbows]: https://rubygems.org/gems/rainbows
58
- # [Reel]: https://github.com/celluloid/reel
59
- # [EventMachine]: https://github.com/eventmachine
60
56
  module DownloadEndpoint
57
+ def self.load_dependencies(uploader, opts = {})
58
+ uploader.plugin :rack_response
59
+ end
60
+
61
61
  def self.configure(uploader, opts = {})
62
62
  uploader.opts[:download_endpoint_storages] = opts.fetch(:storages, uploader.opts[:download_endpoint_storages])
63
63
  uploader.opts[:download_endpoint_prefix] = opts.fetch(:prefix, uploader.opts[:download_endpoint_prefix])
@@ -73,14 +73,28 @@ class Shrine
73
73
  # Assigns the subclass a copy of the download endpoint class.
74
74
  def inherited(subclass)
75
75
  super
76
- subclass.assign_download_endpoint(self::DownloadEndpoint)
76
+ subclass.assign_download_endpoint(@download_endpoint)
77
+ end
78
+
79
+ # Returns the Rack application that retrieves requested files.
80
+ def download_endpoint
81
+ @download_endpoint
77
82
  end
78
83
 
79
84
  # Assigns the subclassed endpoint as the `DownloadEndpoint` constant.
80
85
  def assign_download_endpoint(klass)
81
86
  endpoint_class = Class.new(klass)
82
87
  endpoint_class.opts[:shrine_class] = self
88
+ endpoint_class.opts[:disposition] = opts[:download_endpoint_disposition]
89
+
90
+ @download_endpoint = endpoint_class
91
+
83
92
  const_set(:DownloadEndpoint, endpoint_class)
93
+ deprecate_constant(:DownloadEndpoint) if RUBY_VERSION > "2.3"
94
+ end
95
+
96
+ def download_endpoint_serializer
97
+ @download_endpoint_serializer ||= Serializer.new
84
98
  end
85
99
  end
86
100
 
@@ -90,85 +104,134 @@ class Shrine
90
104
  # of storages it just returns their original URL.
91
105
  def url(**options)
92
106
  if shrine_class.opts[:download_endpoint_storages].include?(storage_key.to_sym)
93
- [
94
- shrine_class.opts[:download_endpoint_host],
95
- *shrine_class.opts[:download_endpoint_prefix],
96
- storage_key,
97
- id,
98
- ].join("/")
107
+ download_url
99
108
  else
100
109
  super
101
110
  end
102
111
  end
112
+
113
+ private
114
+
115
+ def download_url
116
+ [download_host, *download_prefix, download_identifier].join("/")
117
+ end
118
+
119
+ # Generates URL-safe identifier from data, filtering only a subset of
120
+ # metadata that the endpoint needs to prevent the URL from being too
121
+ # long.
122
+ def download_identifier
123
+ semantical_metadata = metadata.select { |name, _| %w[filename size mime_type].include?(name) }
124
+ download_serializer.dump(data.merge("metadata" => semantical_metadata))
125
+ end
126
+
127
+ def download_serializer
128
+ shrine_class.download_endpoint_serializer
129
+ end
130
+
131
+ def download_host
132
+ shrine_class.opts[:download_endpoint_host]
133
+ end
134
+
135
+ def download_prefix
136
+ shrine_class.opts[:download_endpoint_prefix]
137
+ end
103
138
  end
104
139
 
105
140
  # Routes incoming requests. It first asserts that the storage is existent
106
141
  # and allowed. Afterwards it proceeds with the file download using
107
142
  # streaming.
108
143
  class App < Roda
109
- plugin :streaming
110
-
111
144
  route do |r|
112
- r.on ":storage" do |storage_key|
113
- @storage = get_storage(storage_key)
114
-
145
+ # handle legacy ":storage/:id" URLs
146
+ r.on storage_names do |storage_name|
115
147
  r.get /(.*)/ do |id|
116
- filename = request.path.split("/").last
117
- extname = File.extname(filename)
118
-
119
- response["Content-Disposition"] = "#{disposition}; filename=\"#{filename}\""
120
- response["Content-Type"] = Rack::Mime.mime_type(extname)
121
-
122
- io = storage.open(id)
123
- response["Content-Length"] = io.size.to_s if io.size
124
-
125
- stream(callback: ->{io.close}) do |out|
126
- if io.respond_to?(:each_chunk) # Down::ChunkedIO
127
- io.each_chunk { |chunk| out << chunk }
128
- else
129
- out << io.read(16*1024) until io.eof?
130
- end
131
- end
148
+ data = { "id" => id, "storage" => storage_name, "metadata" => {} }
149
+ stream_file(data)
132
150
  end
133
151
  end
152
+
153
+ r.get /(.*)/ do |identifier|
154
+ data = serializer.load(identifier)
155
+ stream_file(data)
156
+ end
134
157
  end
135
158
 
136
159
  private
137
160
 
138
- attr_reader :storage
161
+ def stream_file(data)
162
+ uploaded_file = get_uploaded_file(data)
163
+
164
+ status, headers, body = uploaded_file.to_rack_response(disposition: disposition)
165
+ headers["Cache-Control"] = "max-age=#{365*24*60*60}" # cache for a year
139
166
 
140
- def get_storage(storage_key)
141
- allow_storage!(storage_key)
142
- shrine_class.find_storage(storage_key)
167
+ request.halt [status, headers, body]
143
168
  end
144
169
 
145
- # Halts the request if storage is not allowed.
146
- def allow_storage!(storage_key)
147
- if !allowed_storages.map(&:to_s).include?(storage_key)
148
- error! 403, "Storage #{storage_key.inspect} is not allowed."
149
- end
170
+ # Returns a Shrine::UploadedFile, or returns 404 if file doesn't exist.
171
+ def get_uploaded_file(data)
172
+ uploaded_file = shrine_class.uploaded_file(data)
173
+ not_found! unless uploaded_file.exists?
174
+ uploaded_file
175
+ rescue Shrine::Error
176
+ not_found!
177
+ end
178
+
179
+ def not_found!
180
+ error!(404, "File Not Found")
150
181
  end
151
182
 
152
183
  # Halts the request with the error message.
153
184
  def error!(status, message)
154
185
  response.status = status
155
- response["Content-Type"] = "application/json"
156
- response.write({error: message}.to_json)
186
+ response["Content-Type"] = "text/plain"
187
+ response.write(message)
157
188
  request.halt
158
189
  end
159
190
 
160
- def disposition
161
- shrine_class.opts[:download_endpoint_disposition]
191
+ def storage_names
192
+ shrine_class.storages.keys.map(&:to_s)
162
193
  end
163
194
 
164
- def allowed_storages
165
- shrine_class.opts[:download_endpoint_storages]
195
+ def serializer
196
+ shrine_class.download_endpoint_serializer
197
+ end
198
+
199
+ def disposition
200
+ opts[:disposition]
166
201
  end
167
202
 
168
203
  def shrine_class
169
204
  opts[:shrine_class]
170
205
  end
171
206
  end
207
+
208
+ class Serializer
209
+ def dump(data)
210
+ base64_encode(json_encode(data))
211
+ end
212
+
213
+ def load(data)
214
+ json_decode(base64_decode(data))
215
+ end
216
+
217
+ private
218
+
219
+ def json_encode(data)
220
+ JSON.generate(data)
221
+ end
222
+
223
+ def base64_encode(data)
224
+ Base64.urlsafe_encode64(data)
225
+ end
226
+
227
+ def base64_decode(data)
228
+ Base64.urlsafe_decode64(data)
229
+ end
230
+
231
+ def json_decode(data)
232
+ JSON.parse(data)
233
+ end
234
+ end
172
235
  end
173
236
 
174
237
  register_plugin(:download_endpoint, DownloadEndpoint)
@@ -16,7 +16,7 @@ class Shrine
16
16
  #
17
17
  # For example, the following will keep destroyed and replaced files:
18
18
  #
19
- # plugin :keep_files, destroyed: true, :replaced: true
19
+ # plugin :keep_files, destroyed: true, replaced: true
20
20
  #
21
21
  # [event store]: http://docs.geteventstore.com/introduction/event-sourcing-basics/
22
22
  module KeepFiles
@@ -1,5 +1,6 @@
1
1
  require "logger"
2
2
  require "json"
3
+ require "time"
3
4
 
4
5
  class Shrine
5
6
  module Plugins
@@ -3,7 +3,7 @@ class Shrine
3
3
  # The `metadata_attributes` plugin allows you to sync attachment metadata
4
4
  # to additional record attributes.
5
5
  #
6
- # plugin :metadata_values
6
+ # plugin :metadata_attributes
7
7
  #
8
8
  # It provides `Attacher.metadata_attributes` method which allows you to
9
9
  # specify mappings between metadata fields on the attachment and attribute
@@ -0,0 +1,258 @@
1
+ require "rack"
2
+
3
+ require "json"
4
+
5
+ class Shrine
6
+ module Plugins
7
+ # The `presign_endpoint` plugin provides a Rack endpoint which generates
8
+ # the URL, fields, and headers that can be used to upload files directly to
9
+ # a storage service. It can be used with client-side file upload libraries
10
+ # like [FineUploader], [Dropzone] or [jQuery-File-Upload] for asynchronous
11
+ # uploads. Storage services that support direct uploads include [Amazon
12
+ # S3], [Google Cloud Storage], [Microsoft Azure Storage] and more.
13
+ #
14
+ # plugin :presign_endpoint
15
+ #
16
+ # The plugin adds a `Shrine.presign_endpoint` method which accepts a
17
+ # storage identifier and returns a Rack application that accepts GET
18
+ # requests and generates a presign for the specified storage.
19
+ #
20
+ # Shrine.presign_endpoint(:cache) # rack app
21
+ #
22
+ # Asynchronous upload is typically meant to replace the caching phase in
23
+ # the default synchronous workflow, so we want to generate parameters for
24
+ # uploads to the temporary (`:cache`) storage.
25
+ #
26
+ # We can mount the returned Rack application inside our application:
27
+ #
28
+ # Rails.application.routes.draw do
29
+ # mount Shrine.presign_endpoint(:cache) => "/presign"
30
+ # end
31
+ #
32
+ # The above will create a `GET /presign` endpoint, which generates presign
33
+ # URL, fields, and headers using the specified storage, and returns it in
34
+ # JSON format.
35
+ #
36
+ # # GET /presign
37
+ # {
38
+ # "url": "https://my-bucket.s3-eu-west-1.amazonaws.com",
39
+ # "fields": {
40
+ # "key": "b7d575850ba61b44c8a9ff889dfdb14d88cdc25f8dd121004c8",
41
+ # "policy": "eyJleHBpcmF0aW9uIjoiMjAxNS0QwMToxMToyOVoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJzaHJpbmUtdGVzdGluZyJ9LHsia2V5IjoiYjdkNTc1ODUwYmE2MWI0NGU3Y2M4YTliZmY4OGU5ZGZkYjE2NTQ0ZDk4OGNkYzI1ZjhkZDEyMTAwNGM4In0seyJ4LWFtei1jcmVkZW50aWFsIjoiQUtJQUlKRjU1VE1aWlk0NVVUNlEvMjAxNTEwMjQvZXUtd2VzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjIwMTUxMDI0VDAwMTEyOVoifV19",
42
+ # "x-amz-credential": "AKIAIJF55TMZYT6Q/20151024/eu-west-1/s3/aws4_request",
43
+ # "x-amz-algorithm": "AWS4-HMAC-SHA256",
44
+ # "x-amz-date": "20151024T001129Z",
45
+ # "x-amz-signature": "c1eb634f83f96b69bd675f535b3ff15ae184b102fcba51e4db5f4959b4ae26f4"
46
+ # },
47
+ # "headers": {}
48
+ # }
49
+ #
50
+ # This gives the client all the information it needs to make the upload
51
+ # request to the selected file to the storage service. The `url` field is
52
+ # the request URL, `fields` are the required POST parameters, and `headers`
53
+ # are the required request headers.
54
+ #
55
+ # ## Location
56
+ #
57
+ # By default the generated location won't have any file extension, but you
58
+ # can specify one by sending the `filename` query parameter:
59
+ #
60
+ # GET /presign?filename=nature.jpg
61
+ #
62
+ # It's also possible to customize how the presign location is generated:
63
+ #
64
+ # plugin :presign_endpoint, presign_location: -> (request) do
65
+ # "#{SecureRandom.hex}/#{request.params["filename"]}"
66
+ # end
67
+ #
68
+ # ## Options
69
+ #
70
+ # Some storages accept additional presign options, which you can pass in via
71
+ # `:presign_options`:
72
+ #
73
+ # plugin :presign_endpoint, presign_options: -> (request) do
74
+ # filename = request.params["filename"]
75
+ # extension = File.extname(filename)
76
+ # content_type = Rack::Mime.mime_type(extension)
77
+ #
78
+ # {
79
+ # content_length_range: 0..(10*1024*1024), # limit filesize to 10MB
80
+ # content_disposition: "attachment; filename=\"#{filename}\"", # download with original filename
81
+ # content_type: content_type, # set correct content type
82
+ # }
83
+ # end
84
+ #
85
+ # ## Presign
86
+ #
87
+ # You can also customize how the presign itself is generated via the
88
+ # `:presign` option:
89
+ #
90
+ # plugin :presign_endpoint, presign: -> (id, options, request) do
91
+ # # return an object that responds to #url, #fields, and #headers
92
+ # end
93
+ #
94
+ # ## Response
95
+ #
96
+ # The response returned by the endpoint can be customized via the
97
+ # `:rack_response` option:
98
+ #
99
+ # plugin :presign_endpoint, rack_response: -> (hash, request) do
100
+ # body = { endpoint: hash[:url], params: hash[:fields], headers: hash[:headers] }.to_json
101
+ # [201, { "Content-Type" => "application/json" }, [body]]
102
+ # end
103
+ #
104
+ # ## Ad-hoc options
105
+ #
106
+ # You can override any of the options above when creating the endpoint:
107
+ #
108
+ # Shrine.presign_endpoint(:cache, presign_location: "${filename}")
109
+ #
110
+ # [Amazon S3]: https://aws.amazon.com/s3/
111
+ # [Google Cloud Storage]: https://cloud.google.com/storage/
112
+ # [Microsoft Azure Storage]: https://azure.microsoft.com/en-us/services/storage/
113
+ module PresignEndpoint
114
+ def self.configure(uploader, opts = {})
115
+ uploader.opts[:presign_endpoint_presign_location] = opts.fetch(:presign_location, uploader.opts[:presign_endpoint_presign_location])
116
+ uploader.opts[:presign_endpoint_presign_options] = opts.fetch(:presign_options, uploader.opts[:presign_endpoint_presign_options])
117
+ uploader.opts[:presign_endpoint_presign] = opts.fetch(:presign, uploader.opts[:presign_endpoint_presign])
118
+ uploader.opts[:presign_endpoint_rack_response] = opts.fetch(:rack_response, uploader.opts[:presign_endpoint_rack_response])
119
+ end
120
+
121
+ module ClassMethods
122
+ # Returns a Rack application (object that responds to `#call`) which
123
+ # accepts GET requests to the root URL, calls the specified storage to
124
+ # generate the presign, and returns that information in JSON format.
125
+ #
126
+ # The `storage_key` needs to be one of the registered Shrine storages.
127
+ # Additional options can be given to override the options given on
128
+ # plugin initialization.
129
+ def presign_endpoint(storage_key, **options)
130
+ App.new({
131
+ shrine_class: self,
132
+ storage_key: storage_key,
133
+ presign_location: opts[:presign_endpoint_presign_location],
134
+ presign_options: opts[:presign_endpoint_presign_options],
135
+ presign: opts[:presign_endpoint_presign],
136
+ rack_response: opts[:presign_endpoint_rack_response],
137
+ }.merge(options))
138
+ end
139
+ end
140
+
141
+ # Rack application that accepts GET request to the root URL, calls
142
+ # `#presign` on the specified storage, and returns that information in
143
+ # JSON format.
144
+ class App
145
+ # Writes given options to instance variables.
146
+ def initialize(options)
147
+ options.each do |name, value|
148
+ instance_variable_set("@#{name}", value)
149
+ end
150
+ end
151
+
152
+ # Accepts a Rack env hash, routes GET requests to the root URL, and
153
+ # returns a Rack response triple.
154
+ #
155
+ # If request isn't to the root URL, a `404 Not Found` response is
156
+ # returned. If request verb isn't GET, a `405 Method Not Allowed`
157
+ # response is returned.
158
+ def call(env)
159
+ request = Rack::Request.new(env)
160
+
161
+ status, headers, body = catch(:halt) do
162
+ error!(404, "Not Found") unless ["", "/"].include?(request.path_info)
163
+ error!(405, "Method Not Allowed") unless request.get?
164
+
165
+ handle_request(request)
166
+ end
167
+
168
+ headers["Content-Length"] = body.map(&:bytesize).inject(0, :+).to_s
169
+
170
+ [status, headers, body]
171
+ end
172
+
173
+ private
174
+
175
+ # Accepts a `Rack::Request` object, generates the presign, and returns a
176
+ # Rack response.
177
+ def handle_request(request)
178
+ location = get_presign_location(request)
179
+ options = get_presign_options(request)
180
+
181
+ presign = generate_presign(location, options, request)
182
+
183
+ make_response(presign, request)
184
+ end
185
+
186
+ # Generates the location using `Shrine#generate_uid`, and extracts the
187
+ # extension from the `filename` query parameter. If `:presign_location`
188
+ # option is given, calls that instead.
189
+ def get_presign_location(request)
190
+ if @presign_location
191
+ @presign_location.call(request)
192
+ else
193
+ extension = File.extname(request.params["filename"].to_s)
194
+ uploader.send(:generate_uid, nil) + extension
195
+ end
196
+ end
197
+
198
+ # Calls `:presign_options` option block if given.
199
+ def get_presign_options(request)
200
+ options = @presign_options
201
+ options = options.call(request) if options.respond_to?(:call)
202
+ options || {}
203
+ end
204
+
205
+ # Calls `#presign` on the storage, and returns the `url`, `fields`, and
206
+ # `headers` information in a serialializable format. If `:presign`
207
+ # option is given, calls that instead of calling `#presign`.
208
+ def generate_presign(location, options, request)
209
+ if @presign
210
+ presign = @presign.call(location, options, request)
211
+ else
212
+ presign = storage.presign(location, options)
213
+ end
214
+
215
+ url = presign.url
216
+ fields = presign.fields
217
+ headers = presign.headers if presign.respond_to?(:headers)
218
+
219
+ { url: url, fields: fields.to_h, headers: headers.to_h }
220
+ end
221
+
222
+ # Transforms the presign hash into a JSON response. It returns a Rack
223
+ # response triple - an array consisting of a status number, hash of
224
+ # headers, and a body enumerable. If `:rack_response` option is given,
225
+ # calls that instead.
226
+ def make_response(object, request)
227
+ if @rack_response
228
+ response = @rack_response.call(object, request)
229
+ else
230
+ response = [200, {"Content-Type" => "application/json"}, [object.to_json]]
231
+ end
232
+
233
+ # prevent browsers from caching the response
234
+ response[1]["Cache-Control"] = "no-store" unless response[1].key?("Cache-Control")
235
+
236
+ response
237
+ end
238
+
239
+ # Used for early returning an error response.
240
+ def error!(status, message)
241
+ throw :halt, [status, {"Content-Type" => "text/plain"}, [message]]
242
+ end
243
+
244
+ # Returns the uploader around the specified storage.
245
+ def uploader
246
+ @shrine_class.new(@storage_key)
247
+ end
248
+
249
+ # Returns the storage object.
250
+ def storage
251
+ @shrine_class.find_storage(@storage_key)
252
+ end
253
+ end
254
+ end
255
+
256
+ register_plugin(:presign_endpoint, PresignEndpoint)
257
+ end
258
+ end