shrine 0.9.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +663 -0
- data/doc/creating_plugins.md +100 -0
- data/doc/creating_storages.md +108 -0
- data/doc/direct_s3.md +97 -0
- data/doc/migrating_storage.md +79 -0
- data/doc/regenerating_versions.md +38 -0
- data/lib/shrine.rb +806 -0
- data/lib/shrine/plugins/activerecord.rb +89 -0
- data/lib/shrine/plugins/background_helpers.rb +148 -0
- data/lib/shrine/plugins/cached_attachment_data.rb +47 -0
- data/lib/shrine/plugins/data_uri.rb +93 -0
- data/lib/shrine/plugins/default_storage.rb +39 -0
- data/lib/shrine/plugins/delete_invalid.rb +25 -0
- data/lib/shrine/plugins/determine_mime_type.rb +119 -0
- data/lib/shrine/plugins/direct_upload.rb +274 -0
- data/lib/shrine/plugins/dynamic_storage.rb +57 -0
- data/lib/shrine/plugins/hooks.rb +123 -0
- data/lib/shrine/plugins/included.rb +48 -0
- data/lib/shrine/plugins/keep_files.rb +54 -0
- data/lib/shrine/plugins/logging.rb +158 -0
- data/lib/shrine/plugins/migration_helpers.rb +61 -0
- data/lib/shrine/plugins/moving.rb +75 -0
- data/lib/shrine/plugins/multi_delete.rb +47 -0
- data/lib/shrine/plugins/parallelize.rb +62 -0
- data/lib/shrine/plugins/pretty_location.rb +32 -0
- data/lib/shrine/plugins/recache.rb +36 -0
- data/lib/shrine/plugins/remote_url.rb +127 -0
- data/lib/shrine/plugins/remove_attachment.rb +59 -0
- data/lib/shrine/plugins/restore_cached.rb +36 -0
- data/lib/shrine/plugins/sequel.rb +94 -0
- data/lib/shrine/plugins/store_dimensions.rb +82 -0
- data/lib/shrine/plugins/validation_helpers.rb +168 -0
- data/lib/shrine/plugins/versions.rb +177 -0
- data/lib/shrine/storage/file_system.rb +165 -0
- data/lib/shrine/storage/linter.rb +94 -0
- data/lib/shrine/storage/s3.rb +118 -0
- data/lib/shrine/version.rb +14 -0
- data/shrine.gemspec +46 -0
- metadata +364 -0
@@ -0,0 +1,274 @@
|
|
1
|
+
require "roda"
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "forwardable"
|
5
|
+
require "securerandom"
|
6
|
+
|
7
|
+
class Shrine
|
8
|
+
module Plugins
|
9
|
+
# The direct_upload plugin provides a Rack endpoint (implemented in [Roda])
|
10
|
+
# which you can use to implement AJAX uploads.
|
11
|
+
#
|
12
|
+
# plugin :direct_upload, max_size: 20*1024*1024
|
13
|
+
#
|
14
|
+
# This is how you could mount the endpoint in a Rails application:
|
15
|
+
#
|
16
|
+
# Rails.application.routes.draw do
|
17
|
+
# # adds `POST /attachments/images/:storage/:name`
|
18
|
+
# mount ImageUploader.direct_endpoint => "/attachments/images"
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# Note that you should mount a separate endpoint for each uploader that you
|
22
|
+
# want to use it with. This now gives your Ruby application a
|
23
|
+
# `POST /attachments/images/:storage/:name` route, which accepts a "file"
|
24
|
+
# query parameter:
|
25
|
+
#
|
26
|
+
# $ curl -F "file=@/path/to/avatar.jpg" localhost:3000/attachments/images/cache/avatar
|
27
|
+
# # {"id":"43kewit94.jpg","storage":"cache","metadata":{...}}
|
28
|
+
#
|
29
|
+
# The endpoint returns all responses in JSON format. There are many great
|
30
|
+
# JavaScript libraries for AJAX file uploads, so for example if we have
|
31
|
+
# this form:
|
32
|
+
#
|
33
|
+
# <%= form_for @user do |f| %>
|
34
|
+
# <%= f.hidden_field :avatar, value: @user.avatar_data %>
|
35
|
+
# <%= f.file_field :avatar %>
|
36
|
+
# <% end %>
|
37
|
+
#
|
38
|
+
# this is how we could hook up [jQuery-File-Upload] to our direct upload
|
39
|
+
# endpoint:
|
40
|
+
#
|
41
|
+
# $('[type="file"]').fileupload({
|
42
|
+
# url: '/attachments/images/cache/avatar',
|
43
|
+
# paramName: 'file',
|
44
|
+
# done: function(e, data) { $(this).prev().value(data.result) }
|
45
|
+
# });
|
46
|
+
#
|
47
|
+
# Now whenever a file gets chosen, the upload will automatically start in
|
48
|
+
# the background. It's typically good to show a progress bar to the user,
|
49
|
+
# which jQuery-File-Upload [supports]. After the upload has finished, the
|
50
|
+
# uploaded file JSON is written to the hidden field, and will be sent on
|
51
|
+
# form submit.
|
52
|
+
#
|
53
|
+
# ## Presigned
|
54
|
+
#
|
55
|
+
# An alternative to the direct endpoint is doing direct uploads to the
|
56
|
+
# underlying storage. These uploads usually requires extra information
|
57
|
+
# from the server, and this plugin can provide an endpoint to it, which
|
58
|
+
# you can enable by passing in `presign: true`:
|
59
|
+
#
|
60
|
+
# plugin :direct_upload, presign: true
|
61
|
+
#
|
62
|
+
# This will disable the default `POST /:storage/:name` route (for security
|
63
|
+
# reasons), and enable `GET /:storage/presign`. The response for that
|
64
|
+
# request looks something like this:
|
65
|
+
#
|
66
|
+
# {
|
67
|
+
# "url" => "https://shrine-testing.s3-eu-west-1.amazonaws.com",
|
68
|
+
# "fields" => {
|
69
|
+
# "key" => "b7d575850ba61b44c8a9ff889dfdb14d88cdc25f8dd121004c8",
|
70
|
+
# "policy" => "eyJleHBpcmF0aW9uIjoiMjAxNS0QwMToxMToyOVoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJzaHJpbmUtdGVzdGluZyJ9LHsia2V5IjoiYjdkNTc1ODUwYmE2MWI0NGU3Y2M4YTliZmY4OGU5ZGZkYjE2NTQ0ZDk4OGNkYzI1ZjhkZDEyMTAwNGM4In0seyJ4LWFtei1jcmVkZW50aWFsIjoiQUtJQUlKRjU1VE1aWlk0NVVUNlEvMjAxNTEwMjQvZXUtd2VzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjIwMTUxMDI0VDAwMTEyOVoifV19",
|
71
|
+
# "x-amz-credential" => "AKIAIJF55TMZYT6Q/20151024/eu-west-1/s3/aws4_request",
|
72
|
+
# "x-amz-algorithm" => "AWS4-HMAC-SHA256",
|
73
|
+
# "x-amz-date" => "20151024T001129Z",
|
74
|
+
# "x-amz-signature" => "c1eb634f83f96b69bd675f535b3ff15ae184b102fcba51e4db5f4959b4ae26f4"
|
75
|
+
# }
|
76
|
+
# }
|
77
|
+
#
|
78
|
+
# The `url` is where the file needs to be uploaded to, and `fields` is
|
79
|
+
# additional data that needs to be send on the upload. The `fields.key`
|
80
|
+
# attribute is the location where the file will be uploaded to, it is
|
81
|
+
# generated randomly. `GET /:storage/presign` accepts additional parameters:
|
82
|
+
#
|
83
|
+
# :extension
|
84
|
+
# : The extension of the file being uploaded (e.g ".jpg").
|
85
|
+
#
|
86
|
+
# :content_type
|
87
|
+
# : Sets the Content-Type header for the uploaded file on S3.
|
88
|
+
#
|
89
|
+
# Example:
|
90
|
+
#
|
91
|
+
# GET /cache/presign?content_type=image/jpeg
|
92
|
+
# GET /cache/presign?extension=.png
|
93
|
+
#
|
94
|
+
# ## Constraints
|
95
|
+
#
|
96
|
+
# Note that the direct upload doesn't run validations, they are only run
|
97
|
+
# when attached to the record. If you want to limit the MIME type of files,
|
98
|
+
# you could add an ["accept" attribute] to your file field. You could also
|
99
|
+
# add client side validations for the maximum file size.
|
100
|
+
#
|
101
|
+
# It's encouraged that you set the `:max_size` option for the endpoint.
|
102
|
+
# Once set, when a file that is too big is uploaded, the endpoint will
|
103
|
+
# automatically delete the file and return a 413 response. However, if for
|
104
|
+
# whatever reason you don't want to impose a limit on filesize, you can set
|
105
|
+
# the option to nil:
|
106
|
+
#
|
107
|
+
# plugin :direct_upload, max_size: nil
|
108
|
+
#
|
109
|
+
# ## Allowed storages
|
110
|
+
#
|
111
|
+
# While Shrine only accepts cached attachments on form submits (for security
|
112
|
+
# reasons), you can use this endpoint to upload files to any storage, just
|
113
|
+
# add it do allowed storages:
|
114
|
+
#
|
115
|
+
# plugin :direct_upload, allowed_storages: [:cache, :store]
|
116
|
+
#
|
117
|
+
# ## Authentication
|
118
|
+
#
|
119
|
+
# If you want to authenticate the endpoint, you should be able to do it
|
120
|
+
# easily if your web framework has a good enough router. For example, in
|
121
|
+
# Rails you could add a `constraints` directive:
|
122
|
+
#
|
123
|
+
# Rails.application.routes.draw do
|
124
|
+
# constraints(->(r){r.env["warden"].authenticate!}) do
|
125
|
+
# mount ImageUploader.direct_endpoint => "/attachments/images"
|
126
|
+
# end
|
127
|
+
# end
|
128
|
+
#
|
129
|
+
# [Roda]: https://github.com/jeremyevans/roda
|
130
|
+
# [jQuery-File-Upload]: https://github.com/blueimp/jQuery-File-Upload
|
131
|
+
# [supports]: https://github.com/blueimp/jQuery-File-Upload/wiki/Options#progress
|
132
|
+
# ["accept" attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-accept
|
133
|
+
module DirectUpload
|
134
|
+
def self.configure(uploader, allowed_storages: [:cache], max_size:, presign: nil)
|
135
|
+
uploader.opts[:direct_upload_allowed_storages] = allowed_storages
|
136
|
+
uploader.opts[:direct_upload_max_size] = max_size
|
137
|
+
uploader.opts[:direct_upload_presign] = presign
|
138
|
+
end
|
139
|
+
|
140
|
+
module ClassMethods
|
141
|
+
# Return the cached Roda endpoint.
|
142
|
+
def direct_endpoint
|
143
|
+
@direct_endpoint ||= build_direct_endpoint
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
# Builds the endpoint and assigns it the current Shrine class.
|
149
|
+
def build_direct_endpoint
|
150
|
+
app = Class.new(App)
|
151
|
+
app.opts[:shrine_class] = self
|
152
|
+
app.app
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
class App < Roda
|
157
|
+
plugin :default_headers, "Content-Type"=>"application/json"
|
158
|
+
plugin :halt
|
159
|
+
|
160
|
+
# Routes incoming requests. We first check if the storage is allowed,
|
161
|
+
# then proceed further with the upload, returning the uploaded file
|
162
|
+
# as JSON.
|
163
|
+
route do |r|
|
164
|
+
r.on ":storage" do |storage_key|
|
165
|
+
allow_storage!(storage_key)
|
166
|
+
@uploader = shrine_class.new(storage_key.to_sym)
|
167
|
+
|
168
|
+
r.post ":name" do |name|
|
169
|
+
file = get_file
|
170
|
+
context = {name: name, phase: :cache}
|
171
|
+
|
172
|
+
json @uploader.upload(file, context)
|
173
|
+
end unless presign?
|
174
|
+
|
175
|
+
r.get "presign" do
|
176
|
+
location = SecureRandom.hex(30).to_s + r.params["extension"].to_s
|
177
|
+
options = {}
|
178
|
+
options[:content_length_range] = 0..max_size if max_size
|
179
|
+
options[:content_type] = r.params["content_type"] if r.params["content_type"]
|
180
|
+
|
181
|
+
signature = @uploader.storage.presign(location, options)
|
182
|
+
|
183
|
+
json Hash[url: signature.url, fields: signature.fields]
|
184
|
+
end if presign?
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def json(object)
|
189
|
+
object.to_json
|
190
|
+
end
|
191
|
+
|
192
|
+
# Halts the request if storage is not allowed.
|
193
|
+
def allow_storage!(storage)
|
194
|
+
if !allowed_storages.map(&:to_s).include?(storage)
|
195
|
+
error! 403, "Storage #{storage.inspect} is not allowed."
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Returns the Rack file wrapped in an IO-like object. If "file" is
|
200
|
+
# missing or is too big, the request is halted.
|
201
|
+
def get_file
|
202
|
+
file = require_param!("file")
|
203
|
+
error! 400, "The \"file\" query parameter is not a file." if !(file.is_a?(Hash) && file.key?(:tempfile))
|
204
|
+
check_filesize!(file[:tempfile]) if max_size
|
205
|
+
|
206
|
+
RackFile.new(file)
|
207
|
+
end
|
208
|
+
|
209
|
+
# If the file is too big, deletes the file and halts the request.
|
210
|
+
def check_filesize!(file)
|
211
|
+
if file.size > max_size
|
212
|
+
file.delete
|
213
|
+
megabytes = max_size.to_f / 1024 / 1024
|
214
|
+
error! 413, "The file is too big (maximum size is #{megabytes} MB)."
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Loudly requires the param.
|
219
|
+
def require_param!(name)
|
220
|
+
request.params.fetch(name)
|
221
|
+
rescue KeyError
|
222
|
+
error! 400, "Missing query parameter: #{name.inspect}"
|
223
|
+
end
|
224
|
+
|
225
|
+
# Halts the request with the error message.
|
226
|
+
def error!(status, message)
|
227
|
+
request.halt status, {error: message}.to_json
|
228
|
+
end
|
229
|
+
|
230
|
+
def shrine_class
|
231
|
+
opts[:shrine_class]
|
232
|
+
end
|
233
|
+
|
234
|
+
def allowed_storages
|
235
|
+
shrine_class.opts[:direct_upload_allowed_storages]
|
236
|
+
end
|
237
|
+
|
238
|
+
def max_size
|
239
|
+
shrine_class.opts[:direct_upload_max_size]
|
240
|
+
end
|
241
|
+
|
242
|
+
def presign?
|
243
|
+
shrine_class.opts[:direct_upload_presign]
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
# This is used to wrap the Rack hash into an IO-like object which Shrine
|
248
|
+
# can upload.
|
249
|
+
class RackFile
|
250
|
+
attr_reader :original_filename, :content_type
|
251
|
+
attr_accessor :tempfile
|
252
|
+
|
253
|
+
def initialize(tempfile:, filename: nil, type: nil, **)
|
254
|
+
@tempfile = tempfile
|
255
|
+
@original_filename = filename
|
256
|
+
@content_type = type
|
257
|
+
end
|
258
|
+
|
259
|
+
def path
|
260
|
+
@tempfile.path
|
261
|
+
end
|
262
|
+
|
263
|
+
def to_io
|
264
|
+
@tempfile
|
265
|
+
end
|
266
|
+
|
267
|
+
extend Forwardable
|
268
|
+
delegate Shrine::IO_METHODS.keys => :@tempfile
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
register_plugin(:direct_upload, DirectUpload)
|
273
|
+
end
|
274
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The dynamic_storage plugin allows you to register a storage using a
|
4
|
+
# regex, and evaluate the storage class dynamically depending on the regex.
|
5
|
+
#
|
6
|
+
# Example:
|
7
|
+
#
|
8
|
+
# plugin :dynamic_storage
|
9
|
+
#
|
10
|
+
# storage /store_(\w+)/ do |match|
|
11
|
+
# Shrine::Storages::S3.new(bucket: match[1])
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# The above example uses S3 storage where the bucket name depends on the
|
15
|
+
# storage name suffix. For example, `:store_foo` will use S3 storage which
|
16
|
+
# saves files to the bucket "foo". The block is yielded an instance of
|
17
|
+
# `MatchData`.
|
18
|
+
#
|
19
|
+
# This can be useful in combination with the default_storage plugin.
|
20
|
+
module DynamicStorage
|
21
|
+
module ClassMethods
|
22
|
+
def dynamic_storages
|
23
|
+
@dynamic_storages ||= {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def storage(regex, &block)
|
27
|
+
dynamic_storages[regex] = block
|
28
|
+
end
|
29
|
+
|
30
|
+
def find_storage(name)
|
31
|
+
resolve_dynamic_storage(name) or super
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def resolve_dynamic_storage(name)
|
37
|
+
dynamic_storage_cache.fetch(name) do
|
38
|
+
dynamic_storages.each do |regex, block|
|
39
|
+
if match = name.to_s.match(regex)
|
40
|
+
dynamic_storage_cache[name] = block.call(match)
|
41
|
+
break
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
dynamic_storage_cache[name]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def dynamic_storage_cache
|
50
|
+
@dynamic_storage_cache ||= {}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
register_plugin(:dynamic_storage, DynamicStorage)
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The hooks plugin allows you to trigger some code around
|
4
|
+
# processing/storing/deleting of each file.
|
5
|
+
#
|
6
|
+
# plugin :hooks
|
7
|
+
#
|
8
|
+
# Shrine uses instance methods for hooks. To define a hook for an uploader,
|
9
|
+
# you just add an instance method to the uploader:
|
10
|
+
#
|
11
|
+
# class ImageUploader < Shrine
|
12
|
+
# def around_process(io, context)
|
13
|
+
# super
|
14
|
+
# rescue
|
15
|
+
# ExceptionNotifier.processing_failed(io, context)
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# Each hook will be called with 2 arguments, `io` and `context`. It's
|
20
|
+
# generally good to always call super when overriding a hook, especially if
|
21
|
+
# you're using inheritance with your uploaders.
|
22
|
+
#
|
23
|
+
# Shrine calls hooks in the following order when uploading a file:
|
24
|
+
#
|
25
|
+
# * `around_process`
|
26
|
+
# * `before_process`
|
27
|
+
# * PROCESS
|
28
|
+
# * `after_process`
|
29
|
+
# * `around_store`
|
30
|
+
# * `before_store`
|
31
|
+
# * STORE
|
32
|
+
# * `after_store`
|
33
|
+
#
|
34
|
+
# Shrine calls hooks in the following order when deleting a file:
|
35
|
+
#
|
36
|
+
# * `around_delete`
|
37
|
+
# * `before_delete`
|
38
|
+
# * DELETE
|
39
|
+
# * `after_delete`
|
40
|
+
#
|
41
|
+
# It may be useful to know that you can realize some form of communication
|
42
|
+
# between the hooks; whatever you save to the `context` hash will be
|
43
|
+
# forwarded further down:
|
44
|
+
#
|
45
|
+
# class ImageUploader < Shrine
|
46
|
+
# def before_process(io, context)
|
47
|
+
# context[:_foo] = "bar"
|
48
|
+
# super
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
# def before_store(io, context)
|
52
|
+
# context[:_foo] #=> "bar"
|
53
|
+
# super
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# In that case you should always somehow mark this key as private (for
|
58
|
+
# example with an underscore) so that it doesn't clash with any
|
59
|
+
# existing keys.
|
60
|
+
module Hooks
|
61
|
+
module InstanceMethods
|
62
|
+
def processed(io, context)
|
63
|
+
result = nil
|
64
|
+
around_process(io, context) { result = super }
|
65
|
+
result
|
66
|
+
end
|
67
|
+
private :processed
|
68
|
+
|
69
|
+
def around_process(*args)
|
70
|
+
before_process(*args)
|
71
|
+
yield
|
72
|
+
after_process(*args)
|
73
|
+
end
|
74
|
+
|
75
|
+
def before_process(*)
|
76
|
+
end
|
77
|
+
|
78
|
+
def after_process(*)
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
def store(io, context = {})
|
83
|
+
result = nil
|
84
|
+
around_store(io, context) { result = super }
|
85
|
+
result
|
86
|
+
end
|
87
|
+
|
88
|
+
def around_store(*args)
|
89
|
+
before_store(*args)
|
90
|
+
yield
|
91
|
+
after_store(*args)
|
92
|
+
end
|
93
|
+
|
94
|
+
def before_store(*)
|
95
|
+
end
|
96
|
+
|
97
|
+
def after_store(*)
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
def delete(io, context = {})
|
102
|
+
result = nil
|
103
|
+
around_delete(io, context) { result = super }
|
104
|
+
result
|
105
|
+
end
|
106
|
+
|
107
|
+
def around_delete(*args)
|
108
|
+
before_delete(*args)
|
109
|
+
yield
|
110
|
+
after_delete(*args)
|
111
|
+
end
|
112
|
+
|
113
|
+
def before_delete(*)
|
114
|
+
end
|
115
|
+
|
116
|
+
def after_delete(*)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
register_plugin(:hooks, Hooks)
|
122
|
+
end
|
123
|
+
end
|