shrine 2.5.0 → 2.6.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 +4 -4
- data/README.md +14 -13
- data/doc/attacher.md +7 -6
- data/doc/carrierwave.md +19 -17
- data/doc/design.md +1 -1
- data/doc/direct_s3.md +8 -5
- data/doc/multiple_files.md +4 -4
- data/doc/paperclip.md +7 -6
- data/doc/refile.md +67 -4
- data/doc/securing_uploads.md +41 -25
- data/doc/testing.md +6 -15
- data/lib/shrine.rb +19 -10
- data/lib/shrine/plugins/activerecord.rb +4 -4
- data/lib/shrine/plugins/add_metadata.rb +7 -3
- data/lib/shrine/plugins/background_helpers.rb +1 -1
- data/lib/shrine/plugins/backgrounding.rb +19 -6
- data/lib/shrine/plugins/cached_attachment_data.rb +4 -4
- data/lib/shrine/plugins/data_uri.rb +105 -31
- data/lib/shrine/plugins/default_url.rb +1 -1
- data/lib/shrine/plugins/delete_raw.rb +7 -3
- data/lib/shrine/plugins/determine_mime_type.rb +96 -44
- data/lib/shrine/plugins/direct_upload.rb +3 -1
- data/lib/shrine/plugins/download_endpoint.rb +14 -5
- data/lib/shrine/plugins/logging.rb +4 -4
- data/lib/shrine/plugins/metadata_attributes.rb +61 -0
- data/lib/shrine/plugins/migration_helpers.rb +1 -1
- data/lib/shrine/plugins/rack_file.rb +54 -30
- data/lib/shrine/plugins/recache.rb +1 -1
- data/lib/shrine/plugins/refresh_metadata.rb +29 -0
- data/lib/shrine/plugins/remote_url.rb +26 -4
- data/lib/shrine/plugins/remove_invalid.rb +5 -4
- data/lib/shrine/plugins/restore_cached_data.rb +10 -13
- data/lib/shrine/plugins/sequel.rb +4 -4
- data/lib/shrine/plugins/signature.rb +146 -0
- data/lib/shrine/plugins/store_dimensions.rb +68 -24
- data/lib/shrine/plugins/validation_helpers.rb +48 -29
- data/lib/shrine/plugins/versions.rb +16 -8
- data/lib/shrine/storage/file_system.rb +27 -16
- data/lib/shrine/storage/s3.rb +99 -58
- data/lib/shrine/version.rb +1 -1
- data/shrine.gemspec +1 -1
- metadata +9 -6
@@ -6,7 +6,7 @@ class Shrine
|
|
6
6
|
# plugin :validation_helpers
|
7
7
|
#
|
8
8
|
# Attacher.validate do
|
9
|
-
#
|
9
|
+
# validate_mime_type_inclusion %w[image/jpeg image/png image/gif]
|
10
10
|
# validate_max_size 5*1024*1024 if record.guest?
|
11
11
|
# end
|
12
12
|
#
|
@@ -33,7 +33,7 @@ class Shrine
|
|
33
33
|
# If you would like to change the error message inline, you can pass the
|
34
34
|
# `:message` option to any validation method:
|
35
35
|
#
|
36
|
-
# validate_mime_type_inclusion [
|
36
|
+
# validate_mime_type_inclusion %w[image/jpeg image/png image/gif], message: "must be JPEG, PNG or GIF"
|
37
37
|
#
|
38
38
|
# For a complete list of all validation helpers, see AttacherMethods.
|
39
39
|
module ValidationHelpers
|
@@ -42,16 +42,16 @@ class Shrine
|
|
42
42
|
end
|
43
43
|
|
44
44
|
DEFAULT_MESSAGES = {
|
45
|
-
max_size: ->(max) { "is
|
46
|
-
min_size: ->(min) { "is
|
47
|
-
max_width: ->(max) { "is
|
48
|
-
min_width: ->(min) { "is
|
49
|
-
max_height: ->(max) { "is
|
50
|
-
min_height: ->(min) { "is
|
51
|
-
mime_type_inclusion: ->(list) { "isn't of allowed type: #{list.
|
52
|
-
mime_type_exclusion: ->(list) { "is of forbidden type
|
53
|
-
extension_inclusion: ->(list) { "isn't
|
54
|
-
extension_exclusion: ->(list) { "is
|
45
|
+
max_size: ->(max) { "is too large (max is #{max.to_f/1024/1024} MB)" },
|
46
|
+
min_size: ->(min) { "is too small (min is #{min.to_f/1024/1024} MB)" },
|
47
|
+
max_width: ->(max) { "is too wide (max is #{max} px)" },
|
48
|
+
min_width: ->(min) { "is too narrow (min is #{min} px)" },
|
49
|
+
max_height: ->(max) { "is too tall (max is #{max} px)" },
|
50
|
+
min_height: ->(min) { "is too short (min is #{min} px)" },
|
51
|
+
mime_type_inclusion: ->(list) { "isn't of allowed type (allowed types: #{list.join(", ")})" },
|
52
|
+
mime_type_exclusion: ->(list) { "is of forbidden type" },
|
53
|
+
extension_inclusion: ->(list) { "isn't of allowed format (allowed formats: #{list.join(", ")})" },
|
54
|
+
extension_exclusion: ->(list) { "is of forbidden format" },
|
55
55
|
}
|
56
56
|
|
57
57
|
module AttacherClassMethods
|
@@ -76,61 +76,75 @@ class Shrine
|
|
76
76
|
# `store_dimensions` plugin.
|
77
77
|
def validate_max_width(max, message: nil)
|
78
78
|
raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
|
79
|
-
|
79
|
+
if get.width
|
80
|
+
get.width <= max or add_error(:max_width, message, max) && false
|
81
|
+
else
|
82
|
+
Shrine.deprecation("Width of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if width is nil.")
|
83
|
+
end
|
80
84
|
end
|
81
85
|
|
82
86
|
# Validates that the file is not narrower than `min`. Requires the
|
83
87
|
# `store_dimensions` plugin.
|
84
88
|
def validate_min_width(min, message: nil)
|
85
89
|
raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
|
86
|
-
|
90
|
+
if get.width
|
91
|
+
get.width >= min or add_error(:min_width, message, min) && false
|
92
|
+
else
|
93
|
+
Shrine.deprecation("Width of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if width is nil.")
|
94
|
+
end
|
87
95
|
end
|
88
96
|
|
89
97
|
# Validates that the file is not taller than `max`. Requires the
|
90
98
|
# `store_dimensions` plugin.
|
91
99
|
def validate_max_height(max, message: nil)
|
92
100
|
raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
|
93
|
-
|
101
|
+
if get.height
|
102
|
+
get.height <= max or add_error(:max_height, message, max) && false
|
103
|
+
else
|
104
|
+
Shrine.deprecation("Height of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if height is nil.")
|
105
|
+
end
|
94
106
|
end
|
95
107
|
|
96
108
|
# Validates that the file is not shorter than `min`. Requires the
|
97
109
|
# `store_dimensions` plugin.
|
98
110
|
def validate_min_height(min, message: nil)
|
99
111
|
raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
|
100
|
-
|
112
|
+
if get.height
|
113
|
+
get.height >= min or add_error(:min_height, message, min) && false
|
114
|
+
else
|
115
|
+
Shrine.deprecation("Height of the uploaded file is nil, and Shrine skipped the validation. In Shrine 3 the validation will fail if height is nil.")
|
116
|
+
end
|
101
117
|
end
|
102
118
|
|
103
|
-
# Validates that the MIME type is in the
|
104
|
-
# an array of strings or regexes.
|
119
|
+
# Validates that the MIME type is in the given collection.
|
105
120
|
#
|
106
|
-
# validate_mime_type_inclusion [
|
121
|
+
# validate_mime_type_inclusion %w[audio/mp3 audio/flac]
|
107
122
|
def validate_mime_type_inclusion(whitelist, message: nil)
|
108
123
|
whitelist.any? { |mime_type| regex(mime_type) =~ get.mime_type.to_s } \
|
109
124
|
or add_error(:mime_type_inclusion, message, whitelist) && false
|
110
125
|
end
|
111
126
|
|
112
|
-
# Validates that the MIME type is not in the
|
113
|
-
# is an array of strings or regexes.
|
127
|
+
# Validates that the MIME type is not in the given collection.
|
114
128
|
#
|
115
|
-
# validate_mime_type_exclusion [
|
129
|
+
# validate_mime_type_exclusion %w[text/x-php]
|
116
130
|
def validate_mime_type_exclusion(blacklist, message: nil)
|
117
131
|
blacklist.none? { |mime_type| regex(mime_type) =~ get.mime_type.to_s } \
|
118
132
|
or add_error(:mime_type_exclusion, message, blacklist) && false
|
119
133
|
end
|
120
134
|
|
121
|
-
# Validates that the extension is in the
|
122
|
-
# is
|
135
|
+
# Validates that the extension is in the given collection. Comparison
|
136
|
+
# is case insensitive.
|
123
137
|
#
|
124
|
-
# validate_extension_inclusion [
|
138
|
+
# validate_extension_inclusion %w[jpg jpeg png gif]
|
125
139
|
def validate_extension_inclusion(whitelist, message: nil)
|
126
140
|
whitelist.any? { |extension| regex(extension) =~ get.extension.to_s } \
|
127
141
|
or add_error(:extension_inclusion, message, whitelist) && false
|
128
142
|
end
|
129
143
|
|
130
|
-
# Validates that the extension is not in the
|
131
|
-
# is
|
144
|
+
# Validates that the extension is not in the given collection.
|
145
|
+
# Comparison is case insensitive.
|
132
146
|
#
|
133
|
-
# validate_extension_exclusion [
|
147
|
+
# validate_extension_exclusion %[php jar]
|
134
148
|
def validate_extension_exclusion(blacklist, message: nil)
|
135
149
|
blacklist.none? { |extension| regex(extension) =~ get.extension.to_s } \
|
136
150
|
or add_error(:extension_exclusion, message, blacklist) && false
|
@@ -140,7 +154,12 @@ class Shrine
|
|
140
154
|
|
141
155
|
# Converts a string to a regex.
|
142
156
|
def regex(value)
|
143
|
-
value.is_a?(Regexp)
|
157
|
+
if value.is_a?(Regexp)
|
158
|
+
Shrine.deprecation("Passing regexes to type/extension whitelists/blacklists in validation_helpers plugin is deprecated and will be removed in Shrine 3. Use strings instead.")
|
159
|
+
value
|
160
|
+
else
|
161
|
+
/\A#{Regexp.escape(value)}\z/i
|
162
|
+
end
|
144
163
|
end
|
145
164
|
|
146
165
|
# Generates an error message and appends it to errors array.
|
@@ -14,7 +14,7 @@ class Shrine
|
|
14
14
|
# process(:store) do |io, context|
|
15
15
|
# original = io.download
|
16
16
|
#
|
17
|
-
# size_800 = resize_to_limit!(original, 800, 800)
|
17
|
+
# size_800 = resize_to_limit!(original, 800, 800) { |cmd| cmd.auto_orient }
|
18
18
|
# size_500 = resize_to_limit(size_800, 500, 500)
|
19
19
|
# size_300 = resize_to_limit(size_500, 300, 300)
|
20
20
|
#
|
@@ -49,6 +49,16 @@ class Shrine
|
|
49
49
|
# user.avatar_url(:large)
|
50
50
|
# user.avatar_url(:small, download: true) # with URL options
|
51
51
|
#
|
52
|
+
# `Shrine.uploaded_file` will also instantiate a hash of
|
53
|
+
# `Shrine::UploadedFile` objects if given data with versions. If you want
|
54
|
+
# to apply a change to all files in an attachment, regardless of whether
|
55
|
+
# it consists of a single file or a hash of versions, you can pass a block
|
56
|
+
# to `Shrine.uploaded_file` and it will yield each file:
|
57
|
+
#
|
58
|
+
# Shrine.uploaded_file(attachment_data) do |uploaded_file|
|
59
|
+
# # ...
|
60
|
+
# end
|
61
|
+
#
|
52
62
|
# ## Original file
|
53
63
|
#
|
54
64
|
# If you want to keep the original file, you can include the original
|
@@ -117,7 +127,7 @@ class Shrine
|
|
117
127
|
end
|
118
128
|
|
119
129
|
def self.configure(uploader, opts = {})
|
120
|
-
|
130
|
+
Shrine.deprecation("The versions Shrine plugin doesn't need the :names option anymore, you can safely remove it.") if opts.key?(:names)
|
121
131
|
|
122
132
|
uploader.opts[:version_names] = opts.fetch(:names, uploader.opts[:version_names])
|
123
133
|
uploader.opts[:version_fallbacks] = opts.fetch(:fallbacks, uploader.opts.fetch(:version_fallbacks, {}))
|
@@ -126,7 +136,7 @@ class Shrine
|
|
126
136
|
|
127
137
|
module ClassMethods
|
128
138
|
def version_names
|
129
|
-
|
139
|
+
Shrine.deprecation("Shrine.version_names is deprecated and will be removed in Shrine 3.")
|
130
140
|
opts[:version_names]
|
131
141
|
end
|
132
142
|
|
@@ -136,7 +146,7 @@ class Shrine
|
|
136
146
|
|
137
147
|
# Checks that the identifier is a registered version.
|
138
148
|
def version?(name)
|
139
|
-
|
149
|
+
Shrine.deprecation("Shrine.version? is deprecated and will be removed in Shrine 3.")
|
140
150
|
version_names.nil? || version_names.map(&:to_s).include?(name.to_s)
|
141
151
|
end
|
142
152
|
|
@@ -171,9 +181,7 @@ class Shrine
|
|
171
181
|
def _store(io, context)
|
172
182
|
if (hash = io).is_a?(Hash)
|
173
183
|
raise Error, ":location is not applicable to versions" if context.key?(:location)
|
174
|
-
|
175
|
-
raise Error, "The following version keys point to the same IO object: #{duplicates.inspect}. Shrine cannot upload the same IO object multiple times."
|
176
|
-
end
|
184
|
+
raise Error, "detected multiple versions that point to the same IO object: given versions: #{hash.keys}, unique versions: #{hash.invert.invert.keys}" if hash.invert.invert != hash
|
177
185
|
|
178
186
|
hash.inject({}) do |result, (name, version)|
|
179
187
|
result.update(name => _store(version, version: name, **context))
|
@@ -233,7 +241,7 @@ class Shrine
|
|
233
241
|
|
234
242
|
def assign_cached(value)
|
235
243
|
cached_file = uploaded_file(value)
|
236
|
-
|
244
|
+
Shrine.deprecation("Assigning cached hash of files is deprecated for security reasons and will be removed in Shrine 3.") if cached_file.is_a?(Hash)
|
237
245
|
super(cached_file)
|
238
246
|
end
|
239
247
|
|
@@ -9,10 +9,14 @@ class Shrine
|
|
9
9
|
# storage = Shrine::Storage::FileSystem.new("public", prefix: "uploads")
|
10
10
|
# storage.url("image.jpg") #=> "/uploads/image.jpg"
|
11
11
|
#
|
12
|
-
# This storage will upload all files to "public/uploads", and the URLs
|
13
|
-
#
|
14
|
-
#
|
15
|
-
# "uploads/cache" and other "uploads/store".
|
12
|
+
# This storage will upload all files to "public/uploads", and the URLs of
|
13
|
+
# the uploaded files will start with "/uploads/*". This way you can use
|
14
|
+
# FileSystem for both cache and store, one having the prefix
|
15
|
+
# "uploads/cache" and other "uploads/store". If you're uploading files
|
16
|
+
# to the `public` directory itself, you need to set `:prefix` to `"/"`:
|
17
|
+
#
|
18
|
+
# storage = Shrine::Storage::FileSystem.new("public", prefix: "/") # no prefix
|
19
|
+
# storage.url("image.jpg") #=> "/image.jpg"
|
16
20
|
#
|
17
21
|
# You can also initialize the storage just with the "base" directory, and
|
18
22
|
# then the FileSystem storage will generate absolute URLs to files:
|
@@ -20,6 +24,10 @@ class Shrine
|
|
20
24
|
# storage = Shrine::Storage::FileSystem.new(Dir.tmpdir)
|
21
25
|
# storage.url("image.jpg") #=> "/var/folders/k7/6zx6dx6x7ys3rv3srh0nyfj00000gn/T/image.jpg"
|
22
26
|
#
|
27
|
+
# In general you can always retrieve path to the file using `#path`:
|
28
|
+
#
|
29
|
+
# storage.path("image.jpg") #=> #<Pathname:public/image.jpg>
|
30
|
+
#
|
23
31
|
# ## Host
|
24
32
|
#
|
25
33
|
# It's generally a good idea to serve your files via a CDN, so an
|
@@ -94,7 +102,7 @@ class Shrine
|
|
94
102
|
# deleted, but if it happens that it causes too much load on the
|
95
103
|
# filesystem, you can set this option to `false`.
|
96
104
|
def initialize(directory, prefix: nil, host: nil, clean: true, permissions: 0644, directory_permissions: 0755)
|
97
|
-
|
105
|
+
Shrine.deprecation("The :host option to Shrine::Storage::FileSystem#initialize is deprecated and will be removed in Shrine 3. Pass :host to FileSystem#url instead, you can also use default_url_options plugin.") if host
|
98
106
|
|
99
107
|
if prefix
|
100
108
|
@prefix = Pathname(relative(prefix))
|
@@ -127,7 +135,7 @@ class Shrine
|
|
127
135
|
FileUtils.mv io.path, path!(id)
|
128
136
|
else
|
129
137
|
FileUtils.mv io.storage.path(io.id), path!(id)
|
130
|
-
io.storage.clean(io.id) if io.storage.clean?
|
138
|
+
io.storage.clean(io.storage.path(io.id)) if io.storage.clean?
|
131
139
|
end
|
132
140
|
path(id).chmod(permissions) if permissions
|
133
141
|
end
|
@@ -153,7 +161,7 @@ class Shrine
|
|
153
161
|
# it's empty.
|
154
162
|
def delete(id)
|
155
163
|
path(id).delete
|
156
|
-
clean(id) if clean?
|
164
|
+
clean(path(id)) if clean?
|
157
165
|
rescue Errno::ENOENT
|
158
166
|
end
|
159
167
|
|
@@ -173,7 +181,10 @@ class Shrine
|
|
173
181
|
def clear!(older_than: nil)
|
174
182
|
if older_than
|
175
183
|
directory.find do |path|
|
176
|
-
path.
|
184
|
+
if path.file? && path.mtime < older_than
|
185
|
+
path.delete
|
186
|
+
clean(path) if clean?
|
187
|
+
end
|
177
188
|
end
|
178
189
|
else
|
179
190
|
directory.rmtree
|
@@ -182,10 +193,15 @@ class Shrine
|
|
182
193
|
end
|
183
194
|
end
|
184
195
|
|
196
|
+
# Returns the full path to the file.
|
197
|
+
def path(id)
|
198
|
+
directory.join(id.gsub("/", File::SEPARATOR))
|
199
|
+
end
|
200
|
+
|
185
201
|
# Catches the deprecated `#download` method.
|
186
202
|
def method_missing(name, *args)
|
187
203
|
if name == :download
|
188
|
-
|
204
|
+
Shrine.deprecation("Shrine::Storage::FileSystem#download is deprecated and will be removed in Shrine 3.")
|
189
205
|
require "down"
|
190
206
|
open(*args) { |file| Down.copy_to_tempfile(*args, file) }
|
191
207
|
else
|
@@ -195,14 +211,9 @@ class Shrine
|
|
195
211
|
|
196
212
|
protected
|
197
213
|
|
198
|
-
# Returns the full path to the file.
|
199
|
-
def path(id)
|
200
|
-
directory.join(id.gsub("/", File::SEPARATOR))
|
201
|
-
end
|
202
|
-
|
203
214
|
# Cleans all empty subdirectories up the hierarchy.
|
204
|
-
def clean(
|
205
|
-
path
|
215
|
+
def clean(path)
|
216
|
+
path.dirname.ascend do |pathname|
|
206
217
|
if pathname.children.empty? && pathname != directory
|
207
218
|
pathname.rmdir
|
208
219
|
else
|
data/lib/shrine/storage/s3.rb
CHANGED
@@ -3,6 +3,8 @@ require "down"
|
|
3
3
|
require "uri"
|
4
4
|
require "cgi/util"
|
5
5
|
|
6
|
+
Aws.eager_autoload!(services: ["S3"])
|
7
|
+
|
6
8
|
class Shrine
|
7
9
|
module Storage
|
8
10
|
# The S3 storage handles uploads to Amazon S3 service, using the [aws-sdk]
|
@@ -12,13 +14,25 @@ class Shrine
|
|
12
14
|
#
|
13
15
|
# It is initialized with the following 4 required options:
|
14
16
|
#
|
15
|
-
# Shrine::Storage::S3.new(
|
16
|
-
# access_key_id: "
|
17
|
-
# secret_access_key: "
|
17
|
+
# storage = Shrine::Storage::S3.new(
|
18
|
+
# access_key_id: "abc",
|
19
|
+
# secret_access_key: "xyz",
|
18
20
|
# region: "eu-west-1",
|
19
21
|
# bucket: "my-app",
|
20
22
|
# )
|
21
23
|
#
|
24
|
+
# The storage exposes the underlying Aws objects:
|
25
|
+
#
|
26
|
+
# storage.client #=> #<Aws::S3::Client>
|
27
|
+
# storage.client.access_key_id #=> "abc"
|
28
|
+
# storage.client.secret_access_key #=> "xyz"
|
29
|
+
# storage.client.region #=> "eu-west-1"
|
30
|
+
#
|
31
|
+
# storage.bucket #=> #<Aws::S3::Bucket>
|
32
|
+
# storage.bucket.name #=> "my-app"
|
33
|
+
#
|
34
|
+
# storage.object("key") #=> #<Aws::S3::Object>
|
35
|
+
#
|
22
36
|
# ## Prefix
|
23
37
|
#
|
24
38
|
# The `:prefix` option can be specified for uploading all files inside
|
@@ -33,7 +47,7 @@ class Shrine
|
|
33
47
|
# Sometimes you'll want to add additional upload options to all S3 uploads.
|
34
48
|
# You can do that by passing the `:upload` option:
|
35
49
|
#
|
36
|
-
# Shrine::Storage::S3.new(upload_options: {acl: "
|
50
|
+
# Shrine::Storage::S3.new(upload_options: {acl: "private"}, **s3_options)
|
37
51
|
#
|
38
52
|
# These options will be passed to aws-sdk's methods for [uploading],
|
39
53
|
# [copying] and [presigning].
|
@@ -53,7 +67,7 @@ class Shrine
|
|
53
67
|
#
|
54
68
|
# or when using the uploader directly
|
55
69
|
#
|
56
|
-
# uploader.upload(file, upload_options: {acl: "
|
70
|
+
# uploader.upload(file, upload_options: {acl: "private"})
|
57
71
|
#
|
58
72
|
# Note that, unlike the `:upload_options` storage option, upload options
|
59
73
|
# given on the uploader level won't be forwarded for generating presigns,
|
@@ -64,21 +78,20 @@ class Shrine
|
|
64
78
|
# This storage supports various URL options that will be forwarded from
|
65
79
|
# uploaded file.
|
66
80
|
#
|
67
|
-
#
|
68
|
-
#
|
81
|
+
# uploaded_file.url(public: true) # public URL without signed parameters
|
82
|
+
# uploaded_file.url(download: true) # forced download URL
|
69
83
|
#
|
70
84
|
# All other options are forwarded to the [aws-sdk] gem:
|
71
85
|
#
|
72
|
-
#
|
73
|
-
#
|
86
|
+
# uploaded_file.url(expires_in: 15)
|
87
|
+
# uploaded_file.url(virtual_host: true)
|
74
88
|
#
|
75
89
|
# ## CDN
|
76
90
|
#
|
77
91
|
# If you're using a CDN with S3 like Amazon CloudFront, you can specify
|
78
92
|
# the `:host` option to `#url`:
|
79
93
|
#
|
80
|
-
#
|
81
|
-
# s3.url("image.jpg", host: "http://abc123.cloudfront.net")
|
94
|
+
# uploaded_file.url("image.jpg", host: "http://abc123.cloudfront.net")
|
82
95
|
# #=> "http://abc123.cloudfront.net/image.jpg"
|
83
96
|
#
|
84
97
|
# ## Accelerate endpoint
|
@@ -103,13 +116,24 @@ class Shrine
|
|
103
116
|
# ## Large files
|
104
117
|
#
|
105
118
|
# The [aws-sdk] gem has the ability to automatically use multipart
|
106
|
-
# upload/copy for larger files,
|
107
|
-
#
|
119
|
+
# upload/copy for larger files, splitting the file into multiple chunks
|
120
|
+
# and uploading/copying them in parallel.
|
121
|
+
#
|
122
|
+
# By default any files that are uploaded will use the multipart upload
|
123
|
+
# if they're larger than 15MB, and any files that are copied will use the
|
124
|
+
# multipart copy if they're larger than 150MB, but you can change the
|
125
|
+
# thresholds via `:multipart_threshold`.
|
108
126
|
#
|
109
|
-
#
|
110
|
-
#
|
127
|
+
# thresholds = {upload: 30*1024*1024, copy: 200*1024*1024}
|
128
|
+
# Shrine::Storage::S3.new(multipart_threshold: thresholds, **s3_options)
|
111
129
|
#
|
112
|
-
#
|
130
|
+
# If you want to change how many threads [aws-sdk] will use for multipart
|
131
|
+
# upload/copy, you can use the `upload_options` plugin to specify
|
132
|
+
# `:thread_count`.
|
133
|
+
#
|
134
|
+
# plugin :upload_options, store: ->(io, context) do
|
135
|
+
# {thread_count: 5}
|
136
|
+
# end
|
113
137
|
#
|
114
138
|
# ## Clearing cache
|
115
139
|
#
|
@@ -124,7 +148,7 @@ class Shrine
|
|
124
148
|
# [aws-sdk]: https://github.com/aws/aws-sdk-ruby
|
125
149
|
# [Transfer Acceleration]: http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html
|
126
150
|
class S3
|
127
|
-
attr_reader :
|
151
|
+
attr_reader :client, :bucket, :prefix, :host, :upload_options
|
128
152
|
|
129
153
|
# Initializes a storage for uploading to S3.
|
130
154
|
#
|
@@ -143,8 +167,10 @@ class Shrine
|
|
143
167
|
# and [`Aws::S3::Bucket#presigned_post`].
|
144
168
|
#
|
145
169
|
# :multipart_threshold
|
146
|
-
# :
|
147
|
-
# multipart
|
170
|
+
# : If the input file is larger than the specified size, a parallelized
|
171
|
+
# multipart will be used for the upload/copy. Defaults to
|
172
|
+
# `{upload: 15*1024*1024, copy: 100*1024*1024}` (15MB for upload
|
173
|
+
# requests, 100MB for copy requests).
|
148
174
|
#
|
149
175
|
# All other options are forwarded to [`Aws::S3::Client#initialize`].
|
150
176
|
#
|
@@ -152,19 +178,35 @@ class Shrine
|
|
152
178
|
# [`Aws::S3::Object#copy_from`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#copy_from-instance_method
|
153
179
|
# [`Aws::S3::Bucket#presigned_post`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#presigned_post-instance_method
|
154
180
|
# [`Aws::S3::Client#initialize`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html#initialize-instance_method
|
155
|
-
def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold:
|
156
|
-
|
181
|
+
def initialize(bucket:, prefix: nil, host: nil, upload_options: {}, multipart_threshold: {}, **s3_options)
|
182
|
+
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
|
183
|
+
resource = Aws::S3::Resource.new(**s3_options)
|
157
184
|
|
185
|
+
if multipart_threshold.is_a?(Integer)
|
186
|
+
Shrine.deprecation("Accepting the :multipart_threshold S3 option as an integer is deprecated, use a hash with :upload and :copy keys instead, e.g. {upload: 15*1024*1024, copy: 150*1024*1024}")
|
187
|
+
multipart_threshold = {upload: multipart_threshold}
|
188
|
+
end
|
189
|
+
multipart_threshold[:upload] ||= 15*1024*1024
|
190
|
+
multipart_threshold[:copy] ||= 100*1024*1024
|
191
|
+
|
192
|
+
@bucket = resource.bucket(bucket)
|
193
|
+
@client = resource.client
|
158
194
|
@prefix = prefix
|
159
|
-
@s3 = Aws::S3::Resource.new(**s3_options)
|
160
|
-
@bucket = @s3.bucket(bucket)
|
161
195
|
@host = host
|
162
196
|
@upload_options = upload_options
|
163
197
|
@multipart_threshold = multipart_threshold
|
164
198
|
end
|
165
199
|
|
200
|
+
# Returns an `Aws::S3::Resource` object.
|
201
|
+
def s3
|
202
|
+
Shrine.deprecation("Shrine::Storage::S3#s3 that returns an Aws::S3::Resource is deprecated, use Shrine::Storage::S3#client which returns an Aws::S3::Client object.")
|
203
|
+
Aws::S3::Resource.new(client: @client)
|
204
|
+
end
|
205
|
+
|
166
206
|
# If the file is an UploadedFile from S3, issues a COPY command, otherwise
|
167
|
-
# uploads the file.
|
207
|
+
# uploads the file. For files larger than `:multipart_threshold` a
|
208
|
+
# multipart upload/copy will be used for better performance and more
|
209
|
+
# resilient uploads.
|
168
210
|
#
|
169
211
|
# It assigns the correct "Content-Type" taken from the MIME type, because
|
170
212
|
# by default S3 sets everything to "application/octet-stream".
|
@@ -189,8 +231,8 @@ class Shrine
|
|
189
231
|
|
190
232
|
# Downloads the file from S3, and returns a `Tempfile`.
|
191
233
|
def download(id)
|
192
|
-
tempfile = Tempfile.new(["s3", File.extname(id)], binmode: true)
|
193
|
-
(object = object(id)).get(response_target: tempfile
|
234
|
+
tempfile = Tempfile.new(["shrine-s3", File.extname(id)], binmode: true)
|
235
|
+
(object = object(id)).get(response_target: tempfile)
|
194
236
|
tempfile.singleton_class.instance_eval { attr_accessor :content_type }
|
195
237
|
tempfile.content_type = object.content_type
|
196
238
|
tempfile.tap(&:open)
|
@@ -214,18 +256,18 @@ class Shrine
|
|
214
256
|
# This is called when multiple files are being deleted at once. Issues a
|
215
257
|
# single MULTI DELETE command for each 1000 objects (S3 delete limit).
|
216
258
|
def multi_delete(ids)
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
multi_delete(rest) unless rest.empty?
|
259
|
+
ids.each_slice(1000) do |ids_batch|
|
260
|
+
delete_params = {objects: ids_batch.map { |id| {key: object(id).key} }}
|
261
|
+
bucket.delete_objects(delete: delete_params)
|
262
|
+
end
|
222
263
|
end
|
223
264
|
|
224
265
|
# Returns the presigned URL to the file.
|
225
266
|
#
|
226
267
|
# :public
|
227
|
-
# :
|
228
|
-
# bucket need to be modified to allow
|
268
|
+
# : Controls whether the URL is signed (`false`) or unsigned (`true`).
|
269
|
+
# Note that for unsigned URLs the S3 bucket need to be modified to allow
|
270
|
+
# public URLs. Defaults to `false`.
|
229
271
|
#
|
230
272
|
# :host
|
231
273
|
# : This option replaces the host part of the returned URL, and is
|
@@ -279,10 +321,15 @@ class Shrine
|
|
279
321
|
object(id).presigned_post(options)
|
280
322
|
end
|
281
323
|
|
324
|
+
# Returns an `Aws::S3::Object` for the given id.
|
325
|
+
def object(id)
|
326
|
+
bucket.object([*prefix, id].join("/"))
|
327
|
+
end
|
328
|
+
|
282
329
|
# Catches the deprecated `#stream` method.
|
283
330
|
def method_missing(name, *args)
|
284
331
|
if name == :stream
|
285
|
-
|
332
|
+
Shrine.deprecation("Shrine::Storage::S3#stream is deprecated over calling #each_chunk on S3#open.")
|
286
333
|
object = object(*args)
|
287
334
|
object.get { |chunk| yield chunk, object.content_length }
|
288
335
|
else
|
@@ -290,31 +337,28 @@ class Shrine
|
|
290
337
|
end
|
291
338
|
end
|
292
339
|
|
293
|
-
protected
|
294
|
-
|
295
|
-
# Returns the S3 object.
|
296
|
-
def object(id)
|
297
|
-
bucket.object([*prefix, id].join("/"))
|
298
|
-
end
|
299
|
-
|
300
|
-
# This is used to check whether an S3 file is copyable.
|
301
|
-
def access_key_id
|
302
|
-
s3.client.config.credentials.credentials.access_key_id
|
303
|
-
end
|
304
|
-
|
305
340
|
private
|
306
341
|
|
307
|
-
# Copies an existing S3 object to a new location.
|
342
|
+
# Copies an existing S3 object to a new location. Uses multipart copy for
|
343
|
+
# large files.
|
308
344
|
def copy(io, id, **options)
|
309
|
-
|
345
|
+
# pass :content_length on multipart copy to avoid an additional HEAD request
|
346
|
+
options = {multipart_copy: true, content_length: io.size}.update(options) if io.size && io.size >= @multipart_threshold[:copy]
|
310
347
|
object(id).copy_from(io.storage.object(io.id), **options)
|
311
348
|
end
|
312
349
|
|
313
|
-
# Uploads the file to S3.
|
350
|
+
# Uploads the file to S3. Uses multipart upload for large files.
|
314
351
|
def put(io, id, **options)
|
315
352
|
if io.respond_to?(:path)
|
316
|
-
|
317
|
-
|
353
|
+
path = io.path
|
354
|
+
elsif io.is_a?(UploadedFile) && defined?(Storage::FileSystem) && io.storage.is_a?(Storage::FileSystem)
|
355
|
+
path = io.storage.path(io.id).to_s
|
356
|
+
end
|
357
|
+
|
358
|
+
if path
|
359
|
+
# use `upload_file` for files because it can do multipart upload
|
360
|
+
options = {multipart_threshold: @multipart_threshold[:upload]}.update(options)
|
361
|
+
object(id).upload_file(path, **options)
|
318
362
|
else
|
319
363
|
object(id).put(body: io, **options)
|
320
364
|
end
|
@@ -324,15 +368,12 @@ class Shrine
|
|
324
368
|
def copyable?(io)
|
325
369
|
io.is_a?(UploadedFile) &&
|
326
370
|
io.storage.is_a?(Storage::S3) &&
|
327
|
-
io.storage.access_key_id == access_key_id
|
328
|
-
end
|
329
|
-
|
330
|
-
# Determines whether multipart upload/copy should be used from
|
331
|
-
# `:multipart_threshold`.
|
332
|
-
def multipart?(io)
|
333
|
-
io.size && io.size >= @multipart_threshold
|
371
|
+
io.storage.client.config.access_key_id == client.config.access_key_id
|
334
372
|
end
|
335
373
|
|
374
|
+
# Upload requests will fail if filename has non-ASCII characters, because
|
375
|
+
# of how S3 generates signatures, so we URI-encode them. Most browsers
|
376
|
+
# should automatically URI-decode filenames when downloading.
|
336
377
|
def encode_content_disposition(content_disposition)
|
337
378
|
content_disposition.sub(/(?<=filename=").+(?=")/) do |filename|
|
338
379
|
CGI.escape(filename).sub("+", " ")
|