shrine 1.0.0 → 1.1.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.
Potentially problematic release.
This version of shrine might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/README.md +101 -149
- data/doc/carrierwave.md +12 -16
- data/doc/changing_location.md +50 -0
- data/doc/creating_plugins.md +2 -2
- data/doc/creating_storages.md +70 -9
- data/doc/direct_s3.md +132 -61
- data/doc/migrating_storage.md +12 -10
- data/doc/paperclip.md +12 -17
- data/doc/refile.md +338 -0
- data/doc/regenerating_versions.md +75 -11
- data/doc/securing_uploads.md +172 -0
- data/lib/shrine.rb +21 -16
- data/lib/shrine/plugins/activerecord.rb +2 -2
- data/lib/shrine/plugins/background_helpers.rb +2 -148
- data/lib/shrine/plugins/backgrounding.rb +148 -0
- data/lib/shrine/plugins/backup.rb +88 -0
- data/lib/shrine/plugins/data_uri.rb +25 -4
- data/lib/shrine/plugins/default_url.rb +37 -0
- data/lib/shrine/plugins/delete_uploaded.rb +40 -0
- data/lib/shrine/plugins/determine_mime_type.rb +4 -2
- data/lib/shrine/plugins/direct_upload.rb +107 -62
- data/lib/shrine/plugins/download_endpoint.rb +157 -0
- data/lib/shrine/plugins/hooks.rb +19 -5
- data/lib/shrine/plugins/keep_location.rb +43 -0
- data/lib/shrine/plugins/moving.rb +11 -10
- data/lib/shrine/plugins/parallelize.rb +1 -5
- data/lib/shrine/plugins/parsed_json.rb +7 -1
- data/lib/shrine/plugins/pretty_location.rb +6 -0
- data/lib/shrine/plugins/rack_file.rb +7 -1
- data/lib/shrine/plugins/remove_invalid.rb +22 -0
- data/lib/shrine/plugins/sequel.rb +2 -2
- data/lib/shrine/plugins/upload_options.rb +41 -0
- data/lib/shrine/plugins/versions.rb +9 -7
- data/lib/shrine/storage/file_system.rb +46 -30
- data/lib/shrine/storage/linter.rb +48 -25
- data/lib/shrine/storage/s3.rb +89 -22
- data/lib/shrine/version.rb +1 -1
- data/shrine.gemspec +3 -3
- metadata +16 -5
@@ -0,0 +1,157 @@
|
|
1
|
+
require "roda"
|
2
|
+
|
3
|
+
class Shrine
|
4
|
+
module Plugins
|
5
|
+
# The download_endpoint plugin provides a [Roda] endpoint for downloading
|
6
|
+
# uploaded files from specified storages. This is useful when files from
|
7
|
+
# your storages aren't accessible over URL (e.g. database storages) or if
|
8
|
+
# you want to authenticate your downloads.
|
9
|
+
#
|
10
|
+
# plugin :download_endpoint, storages: [:store], prefix: "attachments"
|
11
|
+
#
|
12
|
+
# After loading the plugin the endpoint should be mounted:
|
13
|
+
#
|
14
|
+
# Rails.appliations.routes.draw do
|
15
|
+
# mount Shrine::DownloadEndpoint => "/attachments"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# Now all stored files can be downloaded through the endpoint, and the
|
19
|
+
# endpoint will efficiently stream the file from the storage when the
|
20
|
+
# storage supports it. `UploadedFile#url` will automatically return the URL
|
21
|
+
# to the endpoint for specified storages, so it's not needed to change the
|
22
|
+
# code:
|
23
|
+
#
|
24
|
+
# user.avatar_url #=> "/attachments/store/sdg0lsf8.jpg"
|
25
|
+
#
|
26
|
+
# :storages
|
27
|
+
# : An array of storage keys which the download endpoint should be used for.
|
28
|
+
#
|
29
|
+
# :prefix
|
30
|
+
# : The location where the download endpoint was mounted. If it was
|
31
|
+
# mounted at the root level, this should be set to nil.
|
32
|
+
#
|
33
|
+
# :host
|
34
|
+
# : The host that you want the download URLs to use (e.g. your app's domain
|
35
|
+
# name or a CDN). By default URLs are relative.
|
36
|
+
#
|
37
|
+
# :disposition
|
38
|
+
# : Can be set to "attachment" if you want that the user is always
|
39
|
+
# prompted to download the file when visiting the download URL.
|
40
|
+
# The default is "inline".
|
41
|
+
#
|
42
|
+
# This plugin is also suitable on Heroku when using FileSystem storage for
|
43
|
+
# cache. On Heroku files cannot be stored to the "public" folder but rather
|
44
|
+
# to the "tmp" folder, which means that by default it's not possible to
|
45
|
+
# show the URL to the cached file. The download endpoint generates the URL
|
46
|
+
# to any file, regardless of its location.
|
47
|
+
#
|
48
|
+
# [Roda]: https://github.com/jeremyevans/roda
|
49
|
+
module DownloadEndpoint
|
50
|
+
def self.configure(uploader, storages:, prefix:, disposition: "inline", host: nil)
|
51
|
+
uploader.opts[:download_endpoint_storages] = storages
|
52
|
+
uploader.opts[:download_endpoint_prefix] = prefix
|
53
|
+
uploader.opts[:download_endpoint_disposition] = disposition
|
54
|
+
uploader.opts[:download_endpoint_host] = host
|
55
|
+
|
56
|
+
uploader.assign_download_endpoint(App) unless uploader.const_defined?(:DownloadEndpoint)
|
57
|
+
end
|
58
|
+
|
59
|
+
module ClassMethods
|
60
|
+
# Assigns the subclass a copy of the download endpoint class.
|
61
|
+
def inherited(subclass)
|
62
|
+
super
|
63
|
+
subclass.assign_download_endpoint(self::DownloadEndpoint)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Assigns the subclassed endpoint as the `DownloadEndpoint` constant.
|
67
|
+
def assign_download_endpoint(klass)
|
68
|
+
endpoint_class = Class.new(klass)
|
69
|
+
endpoint_class.opts[:shrine_class] = self
|
70
|
+
const_set(:DownloadEndpoint, endpoint_class)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
module FileMethods
|
75
|
+
# Constructs the URL from the optional host, prefix, storage key and
|
76
|
+
# uploaded file's id. For other uploaded files that aren't in the list
|
77
|
+
# of storages it just returns their original URL.
|
78
|
+
def url(**options)
|
79
|
+
if shrine_class.opts[:download_endpoint_storages].include?(storage_key.to_sym)
|
80
|
+
[
|
81
|
+
shrine_class.opts[:download_endpoint_host],
|
82
|
+
*shrine_class.opts[:download_endpoint_prefix],
|
83
|
+
storage_key,
|
84
|
+
id,
|
85
|
+
].join("/")
|
86
|
+
else
|
87
|
+
super(options)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Routes incoming requests. It first asserts that the storage is existent
|
93
|
+
# and allowed. Afterwards it proceeds with the file download using
|
94
|
+
# streaming.
|
95
|
+
class App < Roda
|
96
|
+
plugin :streaming
|
97
|
+
|
98
|
+
route do |r|
|
99
|
+
r.on ":storage" do |storage_key|
|
100
|
+
allow_storage!(storage_key)
|
101
|
+
@storage = shrine_class.find_storage(storage_key)
|
102
|
+
|
103
|
+
r.get /(.*)/ do |id|
|
104
|
+
filename = request.path.split("/").last
|
105
|
+
extname = File.extname(filename)
|
106
|
+
|
107
|
+
response["Content-Disposition"] = "#{disposition}; filename=#{filename.inspect}"
|
108
|
+
response["Content-Type"] = Rack::Mime.mime_type(extname)
|
109
|
+
|
110
|
+
stream do |out|
|
111
|
+
if @storage.respond_to?(:stream)
|
112
|
+
@storage.stream(id) { |chunk| out << chunk }
|
113
|
+
else
|
114
|
+
io, buffer = @storage.open(id), ""
|
115
|
+
out << io.read(16384, buffer) until io.eof?
|
116
|
+
io.close
|
117
|
+
io.delete if io.class.name == "Tempfile"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
# Halts the request if storage is not allowed.
|
127
|
+
def allow_storage!(storage)
|
128
|
+
if !allowed_storages.map(&:to_s).include?(storage)
|
129
|
+
error! 403, "Storage #{storage.inspect} is not allowed."
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Halts the request with the error message.
|
134
|
+
def error!(status, message)
|
135
|
+
response.status = status
|
136
|
+
response["Content-Type"] = "application/json"
|
137
|
+
response.write({error: message}.to_json)
|
138
|
+
request.halt
|
139
|
+
end
|
140
|
+
|
141
|
+
def disposition
|
142
|
+
shrine_class.opts[:download_endpoint_disposition]
|
143
|
+
end
|
144
|
+
|
145
|
+
def allowed_storages
|
146
|
+
shrine_class.opts[:download_endpoint_storages]
|
147
|
+
end
|
148
|
+
|
149
|
+
def shrine_class
|
150
|
+
opts[:shrine_class]
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
register_plugin(:download_endpoint, DownloadEndpoint)
|
156
|
+
end
|
157
|
+
end
|
data/lib/shrine/plugins/hooks.rb
CHANGED
@@ -18,7 +18,7 @@ class Shrine
|
|
18
18
|
#
|
19
19
|
# Each hook will be called with 2 arguments, `io` and `context`. You should
|
20
20
|
# always call `super` when overriding a hook, as other plugins may be using
|
21
|
-
# hooks internally, and without `super`
|
21
|
+
# hooks internally, and without `super` those wouldn't get executed.
|
22
22
|
#
|
23
23
|
# Shrine calls hooks in the following order when uploading a file:
|
24
24
|
#
|
@@ -41,6 +41,17 @@ class Shrine
|
|
41
41
|
# * DELETE
|
42
42
|
# * `after_delete`
|
43
43
|
#
|
44
|
+
# By default every `around_*` hook returns the result of the corresponding
|
45
|
+
# operation:
|
46
|
+
#
|
47
|
+
# class ImageUploader < Shrine
|
48
|
+
# def around_store(io, context)
|
49
|
+
# result = super
|
50
|
+
# result.class #=> Shrine::UploadedFile
|
51
|
+
# result # it's good to always return the result for consistent behaviour
|
52
|
+
# end
|
53
|
+
# end
|
54
|
+
#
|
44
55
|
# It may be useful to know that you can realize some form of communication
|
45
56
|
# between the hooks; whatever you save to the `context` hash will be
|
46
57
|
# forwarded further down:
|
@@ -70,8 +81,9 @@ class Shrine
|
|
70
81
|
|
71
82
|
def around_upload(*args)
|
72
83
|
before_upload(*args)
|
73
|
-
yield
|
84
|
+
result = yield
|
74
85
|
after_upload(*args)
|
86
|
+
result
|
75
87
|
end
|
76
88
|
|
77
89
|
def before_upload(*)
|
@@ -90,8 +102,9 @@ class Shrine
|
|
90
102
|
|
91
103
|
def around_process(*args)
|
92
104
|
before_process(*args)
|
93
|
-
yield
|
105
|
+
result = yield
|
94
106
|
after_process(*args)
|
107
|
+
result
|
95
108
|
end
|
96
109
|
|
97
110
|
def before_process(*)
|
@@ -109,8 +122,9 @@ class Shrine
|
|
109
122
|
|
110
123
|
def around_store(*args)
|
111
124
|
before_store(*args)
|
112
|
-
yield
|
125
|
+
result = yield
|
113
126
|
after_store(*args)
|
127
|
+
result
|
114
128
|
end
|
115
129
|
|
116
130
|
def before_store(*)
|
@@ -128,7 +142,7 @@ class Shrine
|
|
128
142
|
|
129
143
|
def around_delete(*args)
|
130
144
|
before_delete(*args)
|
131
|
-
yield
|
145
|
+
result = yield
|
132
146
|
after_delete(*args)
|
133
147
|
end
|
134
148
|
|
@@ -0,0 +1,43 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The keep_location plugin allows you to preserve locations when
|
4
|
+
# transferring files from one storage to another. This can be useful for
|
5
|
+
# debugging purposes or for backups.
|
6
|
+
#
|
7
|
+
# plugin :keep_location, :cache => :store
|
8
|
+
#
|
9
|
+
# The above will preserve location of cached files uploaded to store. More
|
10
|
+
# precisely, if a Shrine::UploadedFile from cache is begin uploaded to
|
11
|
+
# store, the stored file will have the same location as the cached file.
|
12
|
+
#
|
13
|
+
# The destination storage can also be specified as an array:
|
14
|
+
#
|
15
|
+
# plugin :keep_location, :cache => [:storage1, :storage2]
|
16
|
+
module KeepLocation
|
17
|
+
def self.configure(uploader, mappings = {})
|
18
|
+
uploader.opts[:keep_location_mappings] = mappings
|
19
|
+
end
|
20
|
+
|
21
|
+
module InstanceMethods
|
22
|
+
private
|
23
|
+
|
24
|
+
def get_location(io, context)
|
25
|
+
if io.is_a?(UploadedFile) && keep_location?(io)
|
26
|
+
io.id
|
27
|
+
else
|
28
|
+
super
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def keep_location?(uploaded_file)
|
33
|
+
opts[:keep_location_mappings].any? do |source, destination|
|
34
|
+
source == uploaded_file.storage_key.to_sym &&
|
35
|
+
Array(destination).include?(self.storage_key)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
register_plugin(:keep_location, KeepLocation)
|
42
|
+
end
|
43
|
+
end
|
@@ -11,9 +11,9 @@ class Shrine
|
|
11
11
|
# plugin :moving, storages: [:cache]
|
12
12
|
#
|
13
13
|
# The `:storages` option specifies which storages the file will be moved
|
14
|
-
# to. The above will move
|
15
|
-
#
|
16
|
-
#
|
14
|
+
# to. The above will move raw files to cache (without this plugin it's
|
15
|
+
# simply copied over). However, you may want to move cached files to
|
16
|
+
# `:store` as well:
|
17
17
|
#
|
18
18
|
# plugin :moving, storages: [:cache, :store]
|
19
19
|
#
|
@@ -21,7 +21,7 @@ class Shrine
|
|
21
21
|
# being uploaded will be deleted afterwards. However, if both the file
|
22
22
|
# being uploaded and the destination are on the filesystem, a `mv` command
|
23
23
|
# will be executed instead. Some other storages may implement moving as
|
24
|
-
# well, usually if also both the
|
24
|
+
# well, usually if also both the cache and store are using the same
|
25
25
|
# storage.
|
26
26
|
module Moving
|
27
27
|
def self.configure(uploader, storages:)
|
@@ -31,11 +31,9 @@ class Shrine
|
|
31
31
|
module InstanceMethods
|
32
32
|
private
|
33
33
|
|
34
|
-
# If the
|
35
|
-
#
|
36
|
-
|
37
|
-
# after it was uploaded.
|
38
|
-
def put(io, context)
|
34
|
+
# If the storage supports moving we use that, otherwise we do moving by
|
35
|
+
# copying and deleting.
|
36
|
+
def copy(io, context)
|
39
37
|
if move?(io, context)
|
40
38
|
if movable?(io, context)
|
41
39
|
move(io, context)
|
@@ -48,7 +46,10 @@ class Shrine
|
|
48
46
|
end
|
49
47
|
end
|
50
48
|
|
51
|
-
|
49
|
+
def move(io, context)
|
50
|
+
storage.move(io, context[:location], context[:metadata])
|
51
|
+
end
|
52
|
+
|
52
53
|
def movable?(io, context)
|
53
54
|
storage.respond_to?(:move) && storage.movable?(io, context[:location])
|
54
55
|
end
|
@@ -8,12 +8,18 @@ class Shrine
|
|
8
8
|
module ParsedJson
|
9
9
|
module AttacherMethods
|
10
10
|
def assign(value)
|
11
|
-
if value.is_a?(Hash) &&
|
11
|
+
if value.is_a?(Hash) && parsed_json?(value)
|
12
12
|
assign(value.to_json)
|
13
13
|
else
|
14
14
|
super
|
15
15
|
end
|
16
16
|
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def parsed_json?(hash)
|
21
|
+
hash.keys.any? { |key| key.is_a?(String) }
|
22
|
+
end
|
17
23
|
end
|
18
24
|
end
|
19
25
|
|
@@ -25,12 +25,18 @@ class Shrine
|
|
25
25
|
# Checks whether a file is a Rack file hash, and in that case wraps the
|
26
26
|
# hash in an IO-like object.
|
27
27
|
def assign(value)
|
28
|
-
if value.is_a?(Hash) &&
|
28
|
+
if value.is_a?(Hash) && rack_file?(value)
|
29
29
|
assign(UploadedFile.new(value))
|
30
30
|
else
|
31
31
|
super
|
32
32
|
end
|
33
33
|
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def rack_file?(hash)
|
38
|
+
hash.key?(:tempfile)
|
39
|
+
end
|
34
40
|
end
|
35
41
|
|
36
42
|
# This is used to wrap the Rack hash into an IO-like object which Shrine
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The remove_invalid plugin automatically deletes a cached file if it was
|
4
|
+
# invalid and deassigns it from the record.
|
5
|
+
#
|
6
|
+
# plugin :remove_invalid
|
7
|
+
module RemoveInvalid
|
8
|
+
module AttacherMethods
|
9
|
+
def validate
|
10
|
+
super
|
11
|
+
ensure
|
12
|
+
if errors.any? && cache.uploaded?(get)
|
13
|
+
delete!(get, phase: :invalid)
|
14
|
+
_set(nil)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
register_plugin(:remove_invalid, RemoveInvalid)
|
21
|
+
end
|
22
|
+
end
|
@@ -20,7 +20,7 @@ class Shrine
|
|
20
20
|
# tests.
|
21
21
|
#
|
22
22
|
# If you want to put some parts of this lifecycle into a background job, see
|
23
|
-
# the
|
23
|
+
# the backgrounding plugin.
|
24
24
|
#
|
25
25
|
# Additionally, any Shrine validation errors will added to Sequel's
|
26
26
|
# errors upon validation. Note that if you want to validate presence of the
|
@@ -62,7 +62,7 @@ class Shrine
|
|
62
62
|
end
|
63
63
|
|
64
64
|
module AttacherClassMethods
|
65
|
-
# Needed by the
|
65
|
+
# Needed by the backgrounding plugin.
|
66
66
|
def find_record(record_class, record_id)
|
67
67
|
record_class.with_pk!(record_id)
|
68
68
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The upload_options allows you to create additional upload options depending
|
4
|
+
# on file and context, and forward them to the underlying storage.
|
5
|
+
#
|
6
|
+
# plugin :upload_options, store: ->(io, context) do
|
7
|
+
# if [:original, :thumb].include?(context[:version])
|
8
|
+
# {acl: "public-read"}
|
9
|
+
# else
|
10
|
+
# {acl: "private"}
|
11
|
+
# end
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# Keys are names of the registered storages, and values are either blocks
|
15
|
+
# or hashes.
|
16
|
+
module UploadOptions
|
17
|
+
def self.configure(uploader, options = {})
|
18
|
+
uploader.opts[:upload_options_options] = options
|
19
|
+
end
|
20
|
+
|
21
|
+
module InstanceMethods
|
22
|
+
def put(io, context)
|
23
|
+
upload_options = get_upload_options(io, context)
|
24
|
+
key = storage.class.name.split("::").last.downcase
|
25
|
+
context[:metadata][key] = upload_options if upload_options
|
26
|
+
super
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def get_upload_options(io, context)
|
32
|
+
options = opts[:upload_options_options][storage_key]
|
33
|
+
options = options.call(io, context) if options.respond_to?(:call)
|
34
|
+
options
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
register_plugin(:upload_options, UploadOptions)
|
40
|
+
end
|
41
|
+
end
|