shrine 1.4.2 → 2.0.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 +236 -234
- data/doc/changing_location.md +6 -4
- data/doc/creating_storages.md +4 -4
- data/doc/design.md +223 -0
- data/doc/migrating_storage.md +6 -11
- data/doc/regenerating_versions.md +22 -40
- data/lib/shrine.rb +60 -77
- data/lib/shrine/plugins/activerecord.rb +37 -14
- data/lib/shrine/plugins/background_helpers.rb +1 -0
- data/lib/shrine/plugins/backgrounding.rb +49 -37
- data/lib/shrine/plugins/backup.rb +6 -4
- data/lib/shrine/plugins/cached_attachment_data.rb +5 -5
- data/lib/shrine/plugins/data_uri.rb +9 -9
- data/lib/shrine/plugins/default_storage.rb +4 -4
- data/lib/shrine/plugins/default_url.rb +7 -1
- data/lib/shrine/plugins/default_url_options.rb +1 -1
- data/lib/shrine/plugins/delete_promoted.rb +2 -2
- data/lib/shrine/plugins/delete_raw.rb +4 -4
- data/lib/shrine/plugins/determine_mime_type.rb +50 -43
- data/lib/shrine/plugins/direct_upload.rb +10 -20
- data/lib/shrine/plugins/download_endpoint.rb +16 -13
- data/lib/shrine/plugins/dynamic_storage.rb +4 -12
- data/lib/shrine/plugins/included.rb +6 -19
- data/lib/shrine/plugins/keep_files.rb +4 -4
- data/lib/shrine/plugins/logging.rb +4 -4
- data/lib/shrine/plugins/migration_helpers.rb +37 -34
- data/lib/shrine/plugins/moving.rb +19 -32
- data/lib/shrine/plugins/parallelize.rb +5 -5
- data/lib/shrine/plugins/pretty_location.rb +2 -6
- data/lib/shrine/plugins/remote_url.rb +31 -43
- data/lib/shrine/plugins/remove_attachment.rb +5 -5
- data/lib/shrine/plugins/remove_invalid.rb +1 -1
- data/lib/shrine/plugins/restore_cached_data.rb +4 -10
- data/lib/shrine/plugins/sequel.rb +46 -21
- data/lib/shrine/plugins/store_dimensions.rb +19 -20
- data/lib/shrine/plugins/upload_options.rb +11 -9
- data/lib/shrine/plugins/validation_helpers.rb +3 -3
- data/lib/shrine/plugins/versions.rb +18 -3
- data/lib/shrine/storage/file_system.rb +9 -11
- data/lib/shrine/storage/linter.rb +1 -7
- data/lib/shrine/storage/s3.rb +25 -19
- data/lib/shrine/version.rb +3 -3
- data/shrine.gemspec +13 -3
- metadata +28 -9
- data/lib/shrine/plugins/delete_uploaded.rb +0 -3
- data/lib/shrine/plugins/keep_location.rb +0 -46
- data/lib/shrine/plugins/restore_cached.rb +0 -3
@@ -15,65 +15,54 @@ class Shrine
|
|
15
15
|
# user.avatar.size #=> 43423
|
16
16
|
# user.avatar.original_filename #=> "cool-image.png"
|
17
17
|
#
|
18
|
-
# The file will by default be downloaded using
|
19
|
-
#
|
20
|
-
#
|
18
|
+
# The file will by default be downloaded using [Down], which is a wrapper
|
19
|
+
# around the open-uri standard library. It's a good practice to limit the
|
20
|
+
# maximum filesize of the remote file:
|
21
21
|
#
|
22
22
|
# plugin :remote_url, max_size: 20*1024*1024 # 20 MB
|
23
23
|
#
|
24
|
-
# Now if a file that is bigger than 20MB is assigned,
|
25
|
-
#
|
26
|
-
#
|
27
|
-
# you don't want to limit the maximum file
|
28
|
-
# nil:
|
24
|
+
# Now if a file that is bigger than 20MB is assigned, download will be
|
25
|
+
# terminated as soon as it gets the "Content-Length" header, or the
|
26
|
+
# size of currently downloaded content surpasses the maximum size.
|
27
|
+
# However, if for whatever reason you don't want to limit the maximum file
|
28
|
+
# size, you can set `:max_size` to nil:
|
29
29
|
#
|
30
30
|
# plugin :remote_url, max_size: nil
|
31
31
|
#
|
32
|
-
#
|
33
|
-
#
|
32
|
+
# If you need to additionally customize how the file is downloaded, you can
|
33
|
+
# override the `:downloader`:
|
34
34
|
#
|
35
|
-
# plugin :remote_url, downloader: ->(url, max_size:) do
|
36
|
-
#
|
37
|
-
# response = request.execute
|
38
|
-
# response.file
|
35
|
+
# plugin :remote_url, max_size: 20*1024*1024, downloader: ->(url, max_size:) do
|
36
|
+
# Down.download(url, max_size: max_size, max_redirects: 4, read_timeout: 3)
|
39
37
|
# end
|
40
38
|
#
|
41
39
|
# If download errors, the error is rescued and a validation error is added
|
42
40
|
# equal to the error message. You can change the default error message:
|
43
41
|
#
|
44
42
|
# plugin :remote_url, error_message: "download failed"
|
45
|
-
# plugin :remote_url, error_message: ->(url) { I18n.t("errors.download_failed") }
|
43
|
+
# plugin :remote_url, error_message: ->(url, error) { I18n.t("errors.download_failed") }
|
46
44
|
#
|
47
|
-
#
|
48
|
-
# the `:include_error` option will additionally yield the error to the
|
49
|
-
# block:
|
50
|
-
#
|
51
|
-
# plugin :remote_url, include_error: true, error_message: ->(url, error) { "..." }
|
45
|
+
# [Down]: https://github.com/janko-m/down
|
52
46
|
module RemoteUrl
|
53
|
-
def self.
|
54
|
-
|
55
|
-
when :open_uri then require "down"
|
56
|
-
end
|
57
|
-
end
|
47
|
+
def self.configure(uploader, opts = {})
|
48
|
+
raise Error, "The :max_size option is required for remote_url plugin" if !opts.key?(:max_size) && !uploader.opts.key?(:remote_url_max_size)
|
58
49
|
|
59
|
-
|
60
|
-
uploader.opts[:
|
61
|
-
uploader.opts[:
|
62
|
-
uploader.opts[:remote_url_error_message] = error_message
|
63
|
-
uploader.opts[:remote_url_include_error] = include_error
|
50
|
+
uploader.opts[:remote_url_downloader] = opts.fetch(:downloader, uploader.opts.fetch(:remote_url_downloader, :open_uri))
|
51
|
+
uploader.opts[:remote_url_max_size] = opts.fetch(:max_size, uploader.opts[:remote_url_max_size])
|
52
|
+
uploader.opts[:remote_url_error_message] = opts.fetch(:error_message, uploader.opts[:remote_url_error_message])
|
64
53
|
end
|
65
54
|
|
66
55
|
module AttachmentMethods
|
67
|
-
def initialize(
|
56
|
+
def initialize(*)
|
68
57
|
super
|
69
58
|
|
70
59
|
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
71
|
-
def #{name}_remote_url=(url)
|
72
|
-
#{name}_attacher.remote_url = url
|
60
|
+
def #{@name}_remote_url=(url)
|
61
|
+
#{@name}_attacher.remote_url = url
|
73
62
|
end
|
74
63
|
|
75
|
-
def #{name}_remote_url
|
76
|
-
#{name}_attacher.remote_url
|
64
|
+
def #{@name}_remote_url
|
65
|
+
#{@name}_attacher.remote_url
|
77
66
|
end
|
78
67
|
RUBY
|
79
68
|
end
|
@@ -113,26 +102,25 @@ class Shrine
|
|
113
102
|
# is too big.
|
114
103
|
def download(url)
|
115
104
|
downloader = shrine_class.opts[:remote_url_downloader]
|
105
|
+
downloader = method(:"download_with_#{downloader}") if downloader.is_a?(Symbol)
|
116
106
|
max_size = shrine_class.opts[:remote_url_max_size]
|
117
107
|
|
118
|
-
|
119
|
-
send(:"download_with_#{downloader}", url, max_size: max_size)
|
120
|
-
else
|
121
|
-
downloader.call(url, max_size: max_size)
|
122
|
-
end
|
108
|
+
downloader.call(url, max_size: max_size)
|
123
109
|
end
|
124
110
|
|
125
111
|
# We silence any download errors, because for the user's point of view
|
126
112
|
# the download simply failed.
|
127
113
|
def download_with_open_uri(url, max_size:)
|
114
|
+
require "down"
|
128
115
|
Down.download(url, max_size: max_size)
|
129
116
|
end
|
130
117
|
|
131
118
|
def download_error_message(url, error)
|
132
119
|
if message = shrine_class.opts[:remote_url_error_message]
|
133
|
-
|
134
|
-
|
135
|
-
|
120
|
+
if message.respond_to?(:call)
|
121
|
+
args = [url, error].take(message.arity.abs)
|
122
|
+
message = message.call(*args)
|
123
|
+
end
|
136
124
|
else
|
137
125
|
message = "download failed"
|
138
126
|
message = "#{message}: #{error.message}" if error
|
@@ -20,16 +20,16 @@ class Shrine
|
|
20
20
|
# declared somewhere after the hidden field.
|
21
21
|
module RemoveAttachment
|
22
22
|
module AttachmentMethods
|
23
|
-
def initialize(
|
23
|
+
def initialize(*)
|
24
24
|
super
|
25
25
|
|
26
26
|
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
27
|
-
def remove_#{name}=(value)
|
28
|
-
#{name}_attacher.remove = value
|
27
|
+
def remove_#{@name}=(value)
|
28
|
+
#{@name}_attacher.remove = value
|
29
29
|
end
|
30
30
|
|
31
|
-
def remove_#{name}
|
32
|
-
#{name}_attacher.remove
|
31
|
+
def remove_#{@name}
|
32
|
+
#{@name}_attacher.remove
|
33
33
|
end
|
34
34
|
RUBY
|
35
35
|
end
|
@@ -1,14 +1,9 @@
|
|
1
1
|
class Shrine
|
2
2
|
module Plugins
|
3
|
-
# The restore_cached_data plugin ensures the cached file
|
4
|
-
# tampered with,
|
5
|
-
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
# Firstly the assignment is terminated if the cached file doesn't exist,
|
9
|
-
# which can happen if the user changes the "id" or "storage" data. If the
|
10
|
-
# cached file exists, the metadata is reextracted from the original file
|
11
|
-
# and replaced with the potentially tampered with ones.
|
3
|
+
# The restore_cached_data plugin ensures the cached file metadata hasn't
|
4
|
+
# been tampered with, which can be done by modifying the hidden field
|
5
|
+
# before submitting the form. The cached file's metadata will be
|
6
|
+
# reextracted during assignment and replaced with potentially tampered one.
|
12
7
|
#
|
13
8
|
# plugin :restore_cached_data
|
14
9
|
module RestoreCachedData
|
@@ -18,7 +13,6 @@ class Shrine
|
|
18
13
|
def assign_cached(value)
|
19
14
|
cached_file = uploaded_file(value) do |cached_file|
|
20
15
|
next unless cache.uploaded?(cached_file)
|
21
|
-
return unless cached_file.exists?
|
22
16
|
real_metadata = cache.extract_metadata(cached_file.to_io, context)
|
23
17
|
cached_file.metadata.update(real_metadata)
|
24
18
|
cached_file.close
|
@@ -1,7 +1,5 @@
|
|
1
1
|
require "sequel"
|
2
2
|
|
3
|
-
Sequel::Model.plugin :instance_filters
|
4
|
-
|
5
3
|
class Shrine
|
6
4
|
module Plugins
|
7
5
|
# The sequel plugin extends the "attachment" interface with support for
|
@@ -9,10 +7,11 @@ class Shrine
|
|
9
7
|
#
|
10
8
|
# plugin :sequel
|
11
9
|
#
|
12
|
-
#
|
13
|
-
#
|
10
|
+
# ## Callbacks
|
11
|
+
#
|
12
|
+
# Now the attachment module will add additional callbacks to the model:
|
14
13
|
#
|
15
|
-
# * `before_save` --
|
14
|
+
# * `before_save` -- Used by the recached plugin.
|
16
15
|
# * `after_commit` -- Promotes the attachment, deletes replaced ones.
|
17
16
|
# * `after_destroy_commit` -- Deletes the attachment.
|
18
17
|
#
|
@@ -20,8 +19,29 @@ class Shrine
|
|
20
19
|
# `after_commit` callbacks won't get called, so in order to test uploading
|
21
20
|
# you should first disable transactions for those tests.
|
22
21
|
#
|
23
|
-
# If you want to put
|
24
|
-
#
|
22
|
+
# If you want to put promoting/deleting into a background job, see the
|
23
|
+
# backgrounding plugin.
|
24
|
+
#
|
25
|
+
# Since attaching first saves the record with a cached attachment, then
|
26
|
+
# saves again with a stored attachment, you can detect this in callbacks:
|
27
|
+
#
|
28
|
+
# class User < Sequel::Model
|
29
|
+
# include ImageUploader[:avatar]
|
30
|
+
#
|
31
|
+
# def before_save
|
32
|
+
# super
|
33
|
+
#
|
34
|
+
# if changed_columns.include?(:avatar) && avatar_attacher.cached?
|
35
|
+
# # cached
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# if changed_columns.include?(:avatar) && avatar_attacher.stored?
|
39
|
+
# # promoted
|
40
|
+
# end
|
41
|
+
# end
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# ## Validations
|
25
45
|
#
|
26
46
|
# Additionally, any Shrine validation errors will added to Sequel's
|
27
47
|
# errors upon validation. Note that if you want to validate presence of the
|
@@ -33,30 +53,32 @@ class Shrine
|
|
33
53
|
# end
|
34
54
|
module Sequel
|
35
55
|
module AttachmentMethods
|
36
|
-
def
|
56
|
+
def included(model)
|
37
57
|
super
|
38
58
|
|
39
|
-
|
59
|
+
return unless model < ::Sequel::Model
|
60
|
+
|
61
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
40
62
|
def validate
|
41
63
|
super
|
42
|
-
#{name}_attacher.errors.each do |message|
|
43
|
-
errors.add(:#{name}, message)
|
64
|
+
#{@name}_attacher.errors.each do |message|
|
65
|
+
errors.add(:#{@name}, message)
|
44
66
|
end
|
45
67
|
end
|
46
68
|
|
47
69
|
def before_save
|
48
70
|
super
|
49
|
-
#{name}_attacher.save if #{name}_attacher.attached?
|
71
|
+
#{@name}_attacher.save if #{@name}_attacher.attached?
|
50
72
|
end
|
51
73
|
|
52
74
|
def after_commit
|
53
75
|
super
|
54
|
-
#{name}_attacher.finalize if #{name}_attacher.attached?
|
76
|
+
#{@name}_attacher.finalize if #{@name}_attacher.attached?
|
55
77
|
end
|
56
78
|
|
57
79
|
def after_destroy_commit
|
58
80
|
super
|
59
|
-
#{name}_attacher.destroy
|
81
|
+
#{@name}_attacher.destroy
|
60
82
|
end
|
61
83
|
RUBY
|
62
84
|
end
|
@@ -72,18 +94,21 @@ class Shrine
|
|
72
94
|
module AttacherMethods
|
73
95
|
private
|
74
96
|
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
record.send("#{name}_data=", uploaded_file.to_json)
|
80
|
-
record.save(validate: false)
|
81
|
-
end
|
97
|
+
# Proceeds with updating the record unless the attachment has changed.
|
98
|
+
def swap(uploaded_file)
|
99
|
+
return if record.send(:"#{name}_data") != record.reload.send(:"#{name}_data")
|
100
|
+
super
|
82
101
|
rescue ::Sequel::NoExistingObject
|
83
102
|
rescue ::Sequel::Error => error
|
84
103
|
raise unless error.message == "Record not found" # prior to version 4.28
|
85
104
|
end
|
86
105
|
|
106
|
+
# Saves the record after assignment, skipping validations.
|
107
|
+
def update(uploaded_file)
|
108
|
+
super
|
109
|
+
record.save(validate: false)
|
110
|
+
end
|
111
|
+
|
87
112
|
# Support for Postgres JSON columns.
|
88
113
|
def read
|
89
114
|
value = super
|
@@ -17,21 +17,16 @@ class Shrine
|
|
17
17
|
# if for some reason it doesn't suit your needs, you can provide a custom
|
18
18
|
# `:analyzer`:
|
19
19
|
#
|
20
|
-
# plugin :store_dimensions, analyzer: ->(io) do
|
21
|
-
#
|
20
|
+
# plugin :store_dimensions, analyzer: ->(io, analyzers) do
|
21
|
+
# dimensions = analyzers[:fastimage].call(io)
|
22
|
+
# dimensions || MiniMagick::Image.new(io).dimensions
|
22
23
|
# end
|
23
24
|
#
|
24
25
|
# [fastimage]: https://github.com/sdsykes/fastimage
|
25
26
|
# [image bombs]: https://www.bamsoftware.com/hacks/deflate.html
|
26
27
|
module StoreDimensions
|
27
|
-
def self.
|
28
|
-
|
29
|
-
when :fastimage then require "fastimage"
|
30
|
-
end
|
31
|
-
end
|
32
|
-
|
33
|
-
def self.configure(uploader, analyzer: :fastimage)
|
34
|
-
uploader.opts[:dimensions_analyzer] = analyzer
|
28
|
+
def self.configure(uploader, opts = {})
|
29
|
+
uploader.opts[:dimensions_analyzer] = opts.fetch(:analyzer, uploader.opts.fetch(:dimensions_analyzer, :fastimage))
|
35
30
|
end
|
36
31
|
|
37
32
|
module InstanceMethods
|
@@ -45,28 +40,32 @@ class Shrine
|
|
45
40
|
)
|
46
41
|
end
|
47
42
|
|
43
|
+
private
|
44
|
+
|
48
45
|
# If the `io` is an uploaded file, copies its dimensions, otherwise
|
49
46
|
# calls the predefined or custom analyzer.
|
50
47
|
def extract_dimensions(io)
|
51
48
|
analyzer = opts[:dimensions_analyzer]
|
49
|
+
analyzer = dimensions_analyzers[analyzer] if analyzer.is_a?(Symbol)
|
50
|
+
args = [io, dimensions_analyzers].take(analyzer.arity.abs)
|
52
51
|
|
53
|
-
dimensions =
|
54
|
-
[io.width, io.height]
|
55
|
-
elsif analyzer.is_a?(Symbol)
|
56
|
-
send(:"_extract_dimensions_with_#{analyzer}", io)
|
57
|
-
else
|
58
|
-
analyzer.call(io)
|
59
|
-
end
|
60
|
-
|
52
|
+
dimensions = analyzer.call(*args)
|
61
53
|
io.rewind
|
62
54
|
|
63
55
|
dimensions
|
64
56
|
end
|
65
57
|
|
66
|
-
|
58
|
+
def dimensions_analyzers
|
59
|
+
Hash.new { |hash, key| method(:"_extract_dimensions_with_#{key}") }
|
60
|
+
end
|
67
61
|
|
68
62
|
def _extract_dimensions_with_fastimage(io)
|
69
|
-
|
63
|
+
require "fastimage"
|
64
|
+
|
65
|
+
dimensions = FastImage.size(io)
|
66
|
+
io.rewind
|
67
|
+
|
68
|
+
dimensions
|
70
69
|
end
|
71
70
|
end
|
72
71
|
|
@@ -1,7 +1,12 @@
|
|
1
1
|
class Shrine
|
2
2
|
module Plugins
|
3
|
-
# The upload_options allows you to
|
4
|
-
#
|
3
|
+
# The upload_options allows you to automatically pass additional upload
|
4
|
+
# options to storage on every upload:
|
5
|
+
#
|
6
|
+
# plugin :upload_options, cache: {acl: "private"}
|
7
|
+
#
|
8
|
+
# Keys are names of the registered storages, and values are either hashes
|
9
|
+
# or blocks.
|
5
10
|
#
|
6
11
|
# plugin :upload_options, store: ->(io, context) do
|
7
12
|
# if [:original, :thumb].include?(context[:version])
|
@@ -10,26 +15,23 @@ class Shrine
|
|
10
15
|
# {acl: "private"}
|
11
16
|
# end
|
12
17
|
# end
|
13
|
-
#
|
14
|
-
# Keys are names of the registered storages, and values are either blocks
|
15
|
-
# or hashes.
|
16
18
|
module UploadOptions
|
17
19
|
def self.configure(uploader, options = {})
|
18
|
-
uploader.opts[:
|
20
|
+
uploader.opts[:upload_options] ||= {}
|
21
|
+
options.each { |key, value| uploader.opts[:upload_options][key] = value }
|
19
22
|
end
|
20
23
|
|
21
24
|
module InstanceMethods
|
22
25
|
def put(io, context)
|
23
26
|
upload_options = get_upload_options(io, context)
|
24
|
-
|
25
|
-
context[:metadata][key] = upload_options if upload_options
|
27
|
+
context = {upload_options: upload_options}.merge(context)
|
26
28
|
super
|
27
29
|
end
|
28
30
|
|
29
31
|
private
|
30
32
|
|
31
33
|
def get_upload_options(io, context)
|
32
|
-
options = opts[:
|
34
|
+
options = opts[:upload_options][storage_key]
|
33
35
|
options = options.call(io, context) if options.respond_to?(:call)
|
34
36
|
options
|
35
37
|
end
|
@@ -32,8 +32,8 @@ class Shrine
|
|
32
32
|
#
|
33
33
|
# For a complete list of all validation helpers, see AttacherMethods.
|
34
34
|
module ValidationHelpers
|
35
|
-
def self.configure(uploader,
|
36
|
-
uploader.opts[:
|
35
|
+
def self.configure(uploader, opts = {})
|
36
|
+
uploader.opts[:validation_default_messages] = (uploader.opts[:validation_default_messages] || {}).merge(opts[:default_messages] || {})
|
37
37
|
end
|
38
38
|
|
39
39
|
DEFAULT_MESSAGES = {
|
@@ -52,7 +52,7 @@ class Shrine
|
|
52
52
|
module AttacherClassMethods
|
53
53
|
def default_validation_messages
|
54
54
|
@default_validation_messages ||= DEFAULT_MESSAGES.merge(
|
55
|
-
shrine_class.opts[:
|
55
|
+
shrine_class.opts[:validation_default_messages])
|
56
56
|
end
|
57
57
|
end
|
58
58
|
|
@@ -22,6 +22,18 @@ class Shrine
|
|
22
22
|
# end
|
23
23
|
# end
|
24
24
|
#
|
25
|
+
# Note that if you want to keep the original file, you can forward it as is
|
26
|
+
# without explicitly downloading it (since `Shrine::UploadedFile` itself is
|
27
|
+
# an IO-like object), which might avoid downloading depending on the
|
28
|
+
# storage:
|
29
|
+
#
|
30
|
+
# def process(io, context)
|
31
|
+
# if context[:phase] == :store
|
32
|
+
# # ...
|
33
|
+
# {original: io, thumb: thumb}
|
34
|
+
# end
|
35
|
+
# end
|
36
|
+
#
|
25
37
|
# Now when you access the stored attachment through the model, a hash of
|
26
38
|
# uploaded files will be returned:
|
27
39
|
#
|
@@ -96,11 +108,14 @@ class Shrine
|
|
96
108
|
module Versions
|
97
109
|
def self.load_dependencies(uploader, *)
|
98
110
|
uploader.plugin :multi_delete
|
111
|
+
uploader.plugin :default_url
|
99
112
|
end
|
100
113
|
|
101
|
-
def self.configure(uploader,
|
102
|
-
uploader.opts[:version_names] = names
|
103
|
-
uploader.opts[:version_fallbacks] = fallbacks
|
114
|
+
def self.configure(uploader, opts = {})
|
115
|
+
uploader.opts[:version_names] = opts.fetch(:names, uploader.opts[:version_names])
|
116
|
+
uploader.opts[:version_fallbacks] = opts.fetch(:fallbacks, uploader.opts.fetch(:version_fallbacks, {}))
|
117
|
+
|
118
|
+
raise Error, "The :names option is required for versions plugin" if uploader.opts[:version_names].nil?
|
104
119
|
end
|
105
120
|
|
106
121
|
module ClassMethods
|