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.

Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +663 -0
  4. data/doc/creating_plugins.md +100 -0
  5. data/doc/creating_storages.md +108 -0
  6. data/doc/direct_s3.md +97 -0
  7. data/doc/migrating_storage.md +79 -0
  8. data/doc/regenerating_versions.md +38 -0
  9. data/lib/shrine.rb +806 -0
  10. data/lib/shrine/plugins/activerecord.rb +89 -0
  11. data/lib/shrine/plugins/background_helpers.rb +148 -0
  12. data/lib/shrine/plugins/cached_attachment_data.rb +47 -0
  13. data/lib/shrine/plugins/data_uri.rb +93 -0
  14. data/lib/shrine/plugins/default_storage.rb +39 -0
  15. data/lib/shrine/plugins/delete_invalid.rb +25 -0
  16. data/lib/shrine/plugins/determine_mime_type.rb +119 -0
  17. data/lib/shrine/plugins/direct_upload.rb +274 -0
  18. data/lib/shrine/plugins/dynamic_storage.rb +57 -0
  19. data/lib/shrine/plugins/hooks.rb +123 -0
  20. data/lib/shrine/plugins/included.rb +48 -0
  21. data/lib/shrine/plugins/keep_files.rb +54 -0
  22. data/lib/shrine/plugins/logging.rb +158 -0
  23. data/lib/shrine/plugins/migration_helpers.rb +61 -0
  24. data/lib/shrine/plugins/moving.rb +75 -0
  25. data/lib/shrine/plugins/multi_delete.rb +47 -0
  26. data/lib/shrine/plugins/parallelize.rb +62 -0
  27. data/lib/shrine/plugins/pretty_location.rb +32 -0
  28. data/lib/shrine/plugins/recache.rb +36 -0
  29. data/lib/shrine/plugins/remote_url.rb +127 -0
  30. data/lib/shrine/plugins/remove_attachment.rb +59 -0
  31. data/lib/shrine/plugins/restore_cached.rb +36 -0
  32. data/lib/shrine/plugins/sequel.rb +94 -0
  33. data/lib/shrine/plugins/store_dimensions.rb +82 -0
  34. data/lib/shrine/plugins/validation_helpers.rb +168 -0
  35. data/lib/shrine/plugins/versions.rb +177 -0
  36. data/lib/shrine/storage/file_system.rb +165 -0
  37. data/lib/shrine/storage/linter.rb +94 -0
  38. data/lib/shrine/storage/s3.rb +118 -0
  39. data/lib/shrine/version.rb +14 -0
  40. data/shrine.gemspec +46 -0
  41. metadata +364 -0
@@ -0,0 +1,82 @@
1
+ class Shrine
2
+ module Plugins
3
+ # The store_dimensions plugin extracts and stores dimensions of the
4
+ # uploaded image using the [fastimage] gem.
5
+ #
6
+ # plugin :store_dimensions
7
+ #
8
+ # You can access the dimensions through `#width` and `#height` methods:
9
+ #
10
+ # uploader = Shrine.new(:store)
11
+ # uploaded_file = uploader.upload(File.open("image.jpg"))
12
+ #
13
+ # uploaded_file.width #=> 300
14
+ # uploaded_file.height #=> 500
15
+ #
16
+ # The fastimage gem has built-in protection against [image bombs]. However,
17
+ # if for some reason it doesn't suit your needs, you can provide a custom
18
+ # `:analyzer`:
19
+ #
20
+ # plugin :store_dimensions, analyzer: ->(io) do
21
+ # MiniMagick::Image.new(io).dimensions #=> [300, 500]
22
+ # end
23
+ #
24
+ # [fastimage]: https://github.com/sdsykes/fastimage
25
+ # [image bombs]: https://www.bamsoftware.com/hacks/deflate.html
26
+ module StoreDimensions
27
+ def self.load_dependencies(uploader, analyzer: :fastimage)
28
+ case analyzer
29
+ when :fastimage then require "fastimage"
30
+ end
31
+ end
32
+
33
+ def self.configure(uploader, analyzer: :fastimage)
34
+ uploader.opts[:dimensions_analyzer] = analyzer
35
+ end
36
+
37
+ module InstanceMethods
38
+ # We update the metadata with "width" and "height".
39
+ def extract_metadata(io, context)
40
+ width, height = extract_dimensions(io)
41
+
42
+ super.update(
43
+ "width" => width,
44
+ "height" => height,
45
+ )
46
+ end
47
+
48
+ # If the `io` is an uploaded file, copies its dimensions, otherwise
49
+ # calls the predefined or custom analyzer.
50
+ def extract_dimensions(io)
51
+ analyzer = opts[:dimensions_analyzer]
52
+
53
+ if io.respond_to?(:width) && io.respond_to?(:height)
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
+ end
61
+
62
+ private
63
+
64
+ def _extract_dimensions_with_fastimage(io)
65
+ FastImage.size(io)
66
+ end
67
+ end
68
+
69
+ module FileMethods
70
+ def width
71
+ metadata["width"] && Integer(metadata["width"])
72
+ end
73
+
74
+ def height
75
+ metadata["height"] && Integer(metadata["height"])
76
+ end
77
+ end
78
+ end
79
+
80
+ register_plugin(:store_dimensions, StoreDimensions)
81
+ end
82
+ end
@@ -0,0 +1,168 @@
1
+ class Shrine
2
+ module Plugins
3
+ # The validation_helpers plugin provides helper methods for validating
4
+ # attached files.
5
+ #
6
+ # class ImageUploader < Shrine
7
+ # plugin :validation_helpers
8
+ #
9
+ # Attacher.validate do
10
+ # if record.guest?
11
+ # validate_max_size 5*1024*1024
12
+ # end
13
+ # end
14
+ # end
15
+ #
16
+ # The validation methods are instance-level, the `Attacher.validate` block
17
+ # is evaluated in context of an instance of `Shrine::Attacher`, so you can
18
+ # easily to conditional validation.
19
+ #
20
+ # If you would like to change default validation error messages, you can
21
+ # pass in the `:default_messages` option to the plugin:
22
+ #
23
+ # plugin :validation_helpers, default_messages: {
24
+ # max_size: ->(max) { I18n.t("errors.file.max_size", max: max) },
25
+ # mime_type_inclusion: ->(whitelist) { I18n.t("errors.file.mime_type_inclusion", whitelist: whitelist) },
26
+ # }
27
+ #
28
+ # If you would like to change the error message inline, you can pass the
29
+ # `:message` option to any validation method:
30
+ #
31
+ # validate_mime_type_inclusion [/^image/], message: "is not an image"
32
+ #
33
+ # For a complete list of all validation helpers, see AttacherMethods.
34
+ module ValidationHelpers
35
+ def self.configure(uploader, default_messages: {})
36
+ uploader.opts[:validation_helpers_default_messages] = default_messages
37
+ end
38
+
39
+ DEFAULT_MESSAGES = {
40
+ max_size: ->(max) { "is larger than #{max.to_f/1024/1024} MB" },
41
+ min_size: ->(min) { "is smaller than #{min.to_f/1024/1024} MB" },
42
+ max_width: ->(max) { "is wider than #{max} px" },
43
+ min_width: ->(min) { "is narrower than #{min} px" },
44
+ max_height: ->(max) { "is taller than #{max} px" },
45
+ min_height: ->(min) { "is shorter than #{min} px" },
46
+ mime_type_inclusion: ->(list) { "isn't of allowed type: #{list.inspect}" },
47
+ mime_type_exclusion: ->(list) { "is of forbidden type: #{list.inspect}" },
48
+ extension_inclusion: ->(list) { "isn't in allowed format: #{list.inspect}" },
49
+ extension_exclusion: ->(list) { "is in forbidden format: #{list.inspect}" },
50
+ }
51
+
52
+ module AttacherClassMethods
53
+ def default_validation_messages
54
+ @default_validation_messages ||= DEFAULT_MESSAGES.merge(
55
+ shrine_class.opts[:validation_helpers_default_messages])
56
+ end
57
+ end
58
+
59
+ module AttacherMethods
60
+ # Validates that the file is not larger than `max`.
61
+ def validate_max_size(max, message: nil)
62
+ if get.size > max
63
+ errors << error_message(:max_size, message, max)
64
+ end
65
+ end
66
+
67
+ # Validates that the file is not smaller than `min`.
68
+ def validate_min_size(min, message: nil)
69
+ if get.size < min
70
+ errors << error_message(:min_size, message, min)
71
+ end
72
+ end
73
+
74
+ # Validates that the file is not wider than `max`. Requires the
75
+ # store_dimensions plugin.
76
+ def validate_max_width(max, message: nil)
77
+ raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
78
+ if get.width > max
79
+ errors << error_message(:max_width, message, max)
80
+ end
81
+ end
82
+
83
+ # Validates that the file is not narrower than `min`. Requires the
84
+ # store_dimensions plugin.
85
+ def validate_min_width(min, message: nil)
86
+ raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
87
+ if get.width < min
88
+ errors << error_message(:min_width, message, min)
89
+ end
90
+ end
91
+
92
+ # Validates that the file is not taller than `max`. Requires the
93
+ # store_dimensions plugin.
94
+ def validate_max_height(max, message: nil)
95
+ raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
96
+ if get.height > max
97
+ errors << error_message(:max_height, message, max)
98
+ end
99
+ end
100
+
101
+ # Validates that the file is not shorter than `min`. Requires the
102
+ # store_dimensions plugin.
103
+ def validate_min_height(min, message: nil)
104
+ raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
105
+ if get.height < min
106
+ errors << error_message(:min_height, message, min)
107
+ end
108
+ end
109
+
110
+ # Validates that the MIME type is in the `whitelist`. The whitelist is
111
+ # an array of strings or regexes.
112
+ #
113
+ # validate_mime_type_inclusion ["audio/mp3", /^video/]
114
+ def validate_mime_type_inclusion(whitelist, message: nil)
115
+ if whitelist.none? { |mime_type| regex(mime_type) =~ get.mime_type.to_s }
116
+ errors << error_message(:mime_type_inclusion, message, whitelist)
117
+ end
118
+ end
119
+
120
+ # Validates that the MIME type is not in the `blacklist`. The blacklist
121
+ # is an array of strings or regexes.
122
+ #
123
+ # validate_mime_type_exclusion ["image/gif", /^audio/]
124
+ def validate_mime_type_exclusion(blacklist, message: nil)
125
+ if blacklist.any? { |mime_type| regex(mime_type) =~ get.mime_type.to_s }
126
+ errors << error_message(:mime_type_exclusion, message, blacklist)
127
+ end
128
+ end
129
+
130
+ # Validates that the extension is in the `whitelist`. The whitelist
131
+ # is an array of strings or regexes.
132
+ #
133
+ # validate_extension_inclusion [/jpe?g/]
134
+ def validate_extension_inclusion(whitelist, message: nil)
135
+ if whitelist.none? { |extension| regex(extension) =~ get.extension.to_s }
136
+ errors << error_message(:extension_inclusion, message, whitelist)
137
+ end
138
+ end
139
+
140
+ # Validates that the extension is not in the `blacklist`. The blacklist
141
+ # is an array of strings or regexes.
142
+ #
143
+ # validate_extension_exclusion ["mov", /^mp*/]
144
+ def validate_extension_exclusion(blacklist, message: nil)
145
+ if blacklist.any? { |extension| regex(extension) =~ get.extension.to_s }
146
+ errors << error_message(:extension_exclusion, message, blacklist)
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # Converts a string to a regex.
153
+ def regex(string)
154
+ string.is_a?(Regexp) ? string : /^#{Regexp.escape(string)}$/
155
+ end
156
+
157
+ # Returns the direct message if given, otherwise uses the default error
158
+ # message.
159
+ def error_message(type, message, object)
160
+ message ||= self.class.default_validation_messages.fetch(type)
161
+ message.is_a?(String) ? message : message.call(object)
162
+ end
163
+ end
164
+ end
165
+
166
+ register_plugin(:validation_helpers, ValidationHelpers)
167
+ end
168
+ end
@@ -0,0 +1,177 @@
1
+ class Shrine
2
+ module Plugins
3
+ # The versions plugin enables your uploader to deal with versions. To
4
+ # generate versions, you simply return a hash of versions in `Shrine#process`:
5
+ #
6
+ # class ImageUploader < Shrine
7
+ # plugin :versions, names: [:large, :medium, :small]
8
+ #
9
+ # def process(io, context)
10
+ # if context[:phase] == :store
11
+ # size_700 = process_to_limit!(io.download, 700, 700)
12
+ # size_500 = process_to_limit!(size_700, 500, 500)
13
+ # size_300 = process_to_limit!(size_500, 300, 300)
14
+ #
15
+ # {large: size_700, medium: size_500, small: size_300}
16
+ # end
17
+ # end
18
+ # end
19
+ #
20
+ # Now when you access the attachment through the model, a hash of uploaded
21
+ # files will be returned:
22
+ #
23
+ # user.avatar #=>
24
+ # # {
25
+ # # large: #<Shrine::UploadedFile>,
26
+ # # medium: #<Shrine::UploadedFile>,
27
+ # # small: #<Shrine::UploadedFile>,
28
+ # # }
29
+ # user.avatar.class #=> Hash
30
+ #
31
+ # # With the store_dimensions plugin
32
+ # user.avatar[:large].width #=> 700
33
+ # user.avatar[:medium].width #=> 500
34
+ # user.avatar[:small].width #=> 300
35
+ #
36
+ # The plugin also extends the `avatar_url` method to accept versions:
37
+ #
38
+ # user.avatar_url(:medium)
39
+ #
40
+ # This method plays nice when generating versions in a background job,
41
+ # since it will just point to the original cached file until the versions
42
+ # are done processing:
43
+ #
44
+ # user.avatar #=> #<Shrine::UploadedFile>
45
+ # user.avatar_url(:medium) #=> "http://example.com/original.jpg"
46
+ #
47
+ # # the versions have finished generating
48
+ #
49
+ # user.avatar_url(:medium) #=> "http://example.com/medium.jpg"
50
+ #
51
+ # Any additional options will be properly forwarded to the underlying
52
+ # storage:
53
+ #
54
+ # user.avatar_url(:medium, download: true)
55
+ #
56
+ # You can also easily generate default URLs for specific versions, since
57
+ # the `context` will include the version name:
58
+ #
59
+ # class ImageUploader
60
+ # def default_url(io, context)
61
+ # "/images/defaults/#{context[:version]}.jpg"
62
+ # end
63
+ # end
64
+ #
65
+ # When deleting versions, any multi delete capabilities will be leveraged,
66
+ # so when usingStorage::S3, deleting versions will issue only a single HTTP
67
+ # request.
68
+ module Versions
69
+ def self.load_dependencies(uploader, *)
70
+ uploader.plugin :multi_delete
71
+ end
72
+
73
+ def self.configure(uploader, names:)
74
+ uploader.opts[:version_names] = names
75
+ end
76
+
77
+ module ClassMethods
78
+ def version_names
79
+ opts[:version_names]
80
+ end
81
+
82
+ # Checks that the identifier is a registered version.
83
+ def version?(name)
84
+ version_names.map(&:to_s).include?(name.to_s)
85
+ end
86
+
87
+ # Asserts that the hash doesn't contain any unknown versions.
88
+ def versions!(hash)
89
+ hash.select do |name, version|
90
+ version?(name) or raise Error, "unknown version: #{name.inspect}"
91
+ end
92
+ end
93
+
94
+ # Filters the hash to contain only the registered versions.
95
+ def versions(hash)
96
+ hash.select { |name, version| version?(name) }
97
+ end
98
+
99
+ # Converts a hash of data into a hash of versions.
100
+ def uploaded_file(object, &block)
101
+ if object.is_a?(Hash) && !object.key?("storage")
102
+ versions(object).inject({}) do |result, (name, data)|
103
+ result.update(name.to_sym => super(data, &block))
104
+ end
105
+ else
106
+ super
107
+ end
108
+ end
109
+ end
110
+
111
+ module InstanceMethods
112
+ # Checks whether all versions are uploaded by this uploader.
113
+ def uploaded?(uploaded_file)
114
+ if (hash = uploaded_file).is_a?(Hash)
115
+ hash.all? { |name, version| super(version) }
116
+ else
117
+ super
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ # Stores each version individually. It asserts that all versions are
124
+ # known, because later the versions will be silently filtered, so
125
+ # we want to let the user know that they forgot to register a new
126
+ # version.
127
+ def _store(io, context)
128
+ if (hash = io).is_a?(Hash)
129
+ self.class.versions!(hash).inject({}) do |result, (name, version)|
130
+ result.update(name => super(version, version: name, **context))
131
+ end
132
+ else
133
+ super
134
+ end
135
+ end
136
+
137
+ # Deletes each file individually, but uses S3's multi delete
138
+ # capabilities.
139
+ def _delete(uploaded_file, context)
140
+ if (versions = uploaded_file).is_a?(Hash)
141
+ super(versions.values, context)
142
+ versions
143
+ else
144
+ super
145
+ end
146
+ end
147
+ end
148
+
149
+ module AttacherMethods
150
+ # Smart versioned URLs, which include the version name in the default
151
+ # URL, and properly forwards any options to the underlying storage.
152
+ def url(version = nil, **options)
153
+ if get.is_a?(Hash)
154
+ if version
155
+ raise Error, "unknown version: #{version.inspect}" if !shrine_class.version_names.include?(version)
156
+ if file = get[version]
157
+ file.url(**options)
158
+ else
159
+ default_url(options.merge(version: version))
160
+ end
161
+ else
162
+ raise Error, "must call #{name}_url with the name of the version"
163
+ end
164
+ else
165
+ if get || version.nil?
166
+ super(**options)
167
+ else
168
+ default_url(options.merge(version: version))
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ register_plugin(:versions, Versions)
176
+ end
177
+ end
@@ -0,0 +1,165 @@
1
+ require "down"
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ class Shrine
7
+ module Storage
8
+ class FileSystem
9
+ attr_reader :directory, :subdirectory, :host, :permissions
10
+
11
+ # The `directory` is the root directory where uploaded files will be
12
+ # stored. In web applications this is typically the "public/" directory,
13
+ # to make the files available via URL.
14
+ #
15
+ # If `:subdirectory` is given, #url will return a URL relative to
16
+ # `directory` (and include `:subdirectory`). So, `FileSystem.new('public',
17
+ # subdirectory: 'uploads')` will upload files to "public/uploads", and
18
+ # URLs will be "/uploads/*".
19
+ #
20
+ # In applications it's common to serve files over CDN, so an additional
21
+ # `:host` option can be provided. This option can also be used without
22
+ # `:subdirectory`, if for example files are located on another server
23
+ # which requires an IP address.
24
+ #
25
+ # By default FileSystem will clean empty directories when files get
26
+ # deleted. However, if this puts too much load on the filesystem, it can
27
+ # be disabled with `clean: false`.
28
+ #
29
+ # Optional folder and file permissions can be set through the
30
+ # `:permissions` option.
31
+ def initialize(directory, subdirectory: nil, host: nil, clean: true, permissions: nil)
32
+ if subdirectory
33
+ @subdirectory = Pathname(relative(subdirectory))
34
+ @directory = Pathname(directory).join(@subdirectory)
35
+ else
36
+ @directory = Pathname(directory)
37
+ end
38
+
39
+ @host = host
40
+ @permissions = permissions
41
+ @clean = clean
42
+
43
+ @directory.mkpath
44
+ @directory.chmod(permissions) if permissions
45
+ end
46
+
47
+ # Copies the file into the given location.
48
+ def upload(io, id, metadata = {})
49
+ IO.copy_stream(io, path!(id)); io.rewind
50
+ path(id).chmod(permissions) if permissions
51
+ end
52
+
53
+ # Downloads the file from the given location, and returns a `Tempfile`.
54
+ def download(id)
55
+ Down.copy_to_tempfile(id, open(id))
56
+ end
57
+
58
+ # Moves the file to the given location. This gets called by the "moving"
59
+ # plugin.
60
+ def move(io, id, metadata = {})
61
+ if io.respond_to?(:path)
62
+ FileUtils.mv io.path, path!(id)
63
+ else
64
+ FileUtils.mv io.storage.path(io.id), path!(id)
65
+ io.storage.clean(io.id) if io.storage.clean?
66
+ end
67
+ path(id).chmod(permissions) if permissions
68
+ end
69
+
70
+ # Returns true if the file is a `File` or a UploadedFile uploaded by the
71
+ # FileSystem storage.
72
+ def movable?(io, id)
73
+ io.respond_to?(:path) ||
74
+ (io.is_a?(UploadedFile) && io.storage.is_a?(Storage::FileSystem))
75
+ end
76
+
77
+ # Opens the file on the given location in read mode.
78
+ def open(id)
79
+ path(id).open("rb")
80
+ end
81
+
82
+ # Returns the contents of the file as a String.
83
+ def read(id)
84
+ path(id).binread
85
+ end
86
+
87
+ # Returns true if the file exists on the filesystem.
88
+ def exists?(id)
89
+ path(id).exist?
90
+ end
91
+
92
+ # Delets the file, and by default deletes the containing directory if
93
+ # it's empty.
94
+ def delete(id)
95
+ path(id).delete
96
+ clean(id) if clean?
97
+ end
98
+
99
+ # If #subdirectory is present, returns the path relative to #directory,
100
+ # with an optional #host in front. Otherwise returns the full path to the
101
+ # file (also with an optional #host).
102
+ def url(id, **options)
103
+ if subdirectory
104
+ File.join(host || "", subdirectory, id)
105
+ else
106
+ if host
107
+ File.join(host, path(id))
108
+ else
109
+ path(id).to_s
110
+ end
111
+ end
112
+ end
113
+
114
+ # Without any options it deletes all files from the #directory (and this
115
+ # requires confirmation). If `:older_than` is passed in (a `Time`
116
+ # object), deletes all files which were last modified before that time.
117
+ def clear!(confirm = nil, older_than: nil)
118
+ if older_than
119
+ directory.find do |path|
120
+ path.mtime < older_than ? path.rmtree : Find.prune
121
+ end
122
+ else
123
+ raise Shrine::Confirm unless confirm == :confirm
124
+ directory.rmtree
125
+ directory.mkpath
126
+ directory.chmod(permissions) if permissions
127
+ end
128
+ end
129
+
130
+ protected
131
+
132
+ # Returns the full path to the file.
133
+ def path(id)
134
+ directory.join(relative(id))
135
+ end
136
+
137
+ # Cleans all empty subdirectories up the hierarchy.
138
+ def clean(id)
139
+ path(id).dirname.ascend do |pathname|
140
+ if pathname.children.empty? && pathname != directory
141
+ pathname.rmdir
142
+ else
143
+ break
144
+ end
145
+ end
146
+ end
147
+
148
+ def clean?
149
+ @clean
150
+ end
151
+
152
+ private
153
+
154
+ # Creates all intermediate directories for that location.
155
+ def path!(id)
156
+ path(id).dirname.mkpath
157
+ path(id)
158
+ end
159
+
160
+ def relative(path)
161
+ path.sub(%r{^/}, "")
162
+ end
163
+ end
164
+ end
165
+ end