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,100 @@
1
+ # Creating a new plugin
2
+
3
+ Shrine has a lot of plugins built-in, but you can also easily create your own.
4
+ Simply put, a plugin is a module:
5
+
6
+ ```rb
7
+ module MyPlugin
8
+ # ...
9
+ end
10
+
11
+ Shrine.plugin MyPlugin
12
+ ```
13
+
14
+ If you would like to load plugins with a symbol, like you already load plugins
15
+ that ship with Shrine, you need to put the plugin in
16
+ `shrine/plugins/my_plugin.rb` in the load path, and register it:
17
+
18
+ ```rb
19
+ # shrine/plugins/my_plugin.rb
20
+ class Shrine
21
+ module Plugins
22
+ module MyPlugin
23
+ # ...
24
+ end
25
+
26
+ register_plugin(:my_plugin, MyPlugin)
27
+ end
28
+ end
29
+ ```
30
+ ```rb
31
+ Shrine.plugin :my_plugin
32
+ ```
33
+
34
+ The way to make plugins actually extend Shrine's core classes is by defining
35
+ special modules inside the plugin. Here's a list of all "special" modules:
36
+
37
+ ```rb
38
+ InstanceMethods # gets included into `Shrine`
39
+ ClassMethods # gets extended into `Shrine`
40
+ AttachmentMethods # gets included into `Shrine::Attachment`
41
+ AttachmentClassMethods # gets extended into `Shrine::Attachment`
42
+ AttacherMethods # gets included into `Shrine::Attacher`
43
+ AttacherClassMethods # gets extended into `Shrine::Attacher`
44
+ FileMethods # gets included into `Shrine::UploadedFile`
45
+ FileClassMethods # gets extended into `Shrine::UploadedFile`
46
+ ```
47
+
48
+ For example, this is how you would make your plugin add some logging to
49
+ uploading:
50
+
51
+ ```rb
52
+ module MyPlugin
53
+ module InstanceMethods
54
+ def upload(io, context)
55
+ time = Time.now
56
+ result = super
57
+ duration = Time.now - time
58
+ puts "Upload duration: #{duration}s"
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ Notice that we can call `super` to get the original behaviour. In addition to
65
+ these modules, you can also make your plugin configurable:
66
+
67
+ ```rb
68
+ Shrine.plugin :my_plugin, foo: "bar"
69
+ ```
70
+
71
+ You can do this my adding a `.configure` method to your plugin, which will be
72
+ given any passed in arguments or blocks. Typically you'll want to save these
73
+ options into Shrine's `opts`, so that you can access them inside of Shrine's
74
+ methods.
75
+
76
+ ```rb
77
+ module MyPlugin
78
+ def self.configure(uploader, options = {})
79
+ uploader # The uploader class which called `.plugin`
80
+ uploader.opts[:my_plugin_options] = options
81
+ end
82
+
83
+ module InstanceMethods
84
+ def foo
85
+ opts[:my_plugin_options] #=> {foo: "bar"}
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ If your plugin depends on other plugins, you can load them inside of
92
+ `.load_dependencies` (which is given the same arguments as `.configure`):
93
+
94
+ ```rb
95
+ module MyPlugin
96
+ def self.load_dependencies(uploader, *)
97
+ uploader.plugin :versions # depends on the versions plugin
98
+ end
99
+ end
100
+ ```
@@ -0,0 +1,108 @@
1
+ # Creating a new storage
2
+
3
+ Shrine ships with the FileSystem and S3 storages, but it's also easy to create
4
+ your own. A storage is a class which has at least the following methods:
5
+
6
+ ```rb
7
+ class Shrine
8
+ module Storage
9
+ class MyStorage
10
+ def initialize(*args)
11
+ # initializing logic
12
+ end
13
+
14
+ def upload(io, id, metadata = {})
15
+ # uploads `io` to the location `id`
16
+ end
17
+
18
+ def download(id)
19
+ # downloads the file from the storage
20
+ end
21
+
22
+ def open(id)
23
+ # returns the remote file as an IO-like object
24
+ end
25
+
26
+ def read(id)
27
+ # returns the file contents as a string
28
+ end
29
+
30
+ def exists?(id)
31
+ # checks if the file exists on the storage
32
+ end
33
+
34
+ def delete(id)
35
+ # deletes the file from the storage
36
+ end
37
+
38
+ def url(id, options = {})
39
+ # URL to the remote file, accepts options for customizing the URL
40
+ end
41
+
42
+ def clear!(confirm = nil)
43
+ # deletes all the files in the storage
44
+ end
45
+ end
46
+ end
47
+ end
48
+ ```
49
+
50
+ To check that your storage implements all these methods correctly, you can use
51
+ `Shrine::Storage::Linter` in tests:
52
+
53
+ ```rb
54
+ require "shrine/storage/linter"
55
+
56
+ storage = Shrine::Storage::MyStorage.new(*args)
57
+ Shrine::Storage::Linter.call(storage)
58
+ ```
59
+
60
+ The linter will pass real files through your storage, and raise an error with
61
+ an appropriate message if a part of the specification isn't satisfied.
62
+
63
+ ## Moving
64
+
65
+ If your storage can move files, you can add 2 additional methods, and they will
66
+ automatically get used by the `moving` plugin:
67
+
68
+ ```rb
69
+ class Shrine
70
+ module Storage
71
+ class MyStorage
72
+ # ...
73
+
74
+ def move(io, id, metadata = {})
75
+ # does the moving of the `io` to the location `id`
76
+ end
77
+
78
+ def movable?(io, id)
79
+ # whether the given `io` is movable, to the location `id`
80
+ end
81
+
82
+ # ...
83
+ end
84
+ end
85
+ end
86
+ ```
87
+
88
+ ## Multi delete
89
+
90
+ If your storage supports deleting multiple files at the same time, you can
91
+ implement an additional method, which will automatically get picked up by the
92
+ `multi_delete` plugin:
93
+
94
+ ```rb
95
+ class Shrine
96
+ module Storage
97
+ class MyStorage
98
+ # ...
99
+
100
+ def multi_delete(ids)
101
+ # deletes multiple files at once
102
+ end
103
+
104
+ # ...
105
+ end
106
+ end
107
+ end
108
+ ```
@@ -0,0 +1,97 @@
1
+ # Direct uploads to S3
2
+
3
+ Probably the best way to do file uploads is to upload them directly to S3, and
4
+ afterwards do processing in a background job. Direct S3 uploads are a bit more
5
+ involved, so we'll explain the process.
6
+
7
+ ## Enabling CORS
8
+
9
+ First thing that we need to do is enable CORS on our S3 bucket. You can do that
10
+ by clicking on "Properties > Permissions > Add CORS Configuration", and
11
+ then just follow the Amazon documentation on how to write a CORS file.
12
+
13
+ http://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html
14
+
15
+ Note that it may take some time for the CORS settings to be applied, due to
16
+ DNS propagation.
17
+
18
+ ## Static upload
19
+
20
+ If you're doing just a single file upload in your form, you can generate
21
+ upfront the fields necessary for direct S3 uploads using
22
+ `Shrine::Storage::S3#presign`. This method returns a [`Aws::S3::PresignedPost`]
23
+ object, which has `#url` and `#fields`, which you could use like this:
24
+
25
+ ```erb
26
+ <% presign = Shrine.storages[:cache].presign(SecureRandom.hex.to_s) %>
27
+
28
+ <form action="<%= presign.url %>" method="post" enctype="multipart/form-data">
29
+ <input type="file" name="file">
30
+ <% presign.fields.each do |name, value| %>
31
+ <input type="hidden" name="<%= name %>" value="<%= value %>">
32
+ <% end %>
33
+ </form>
34
+ ```
35
+
36
+ ## Dynamic upload
37
+
38
+ If the frontend is separate from the backend, or you want to do multiple file
39
+ uploads, you need to generate these presigns dynamically. The `direct_upload`
40
+ plugins provides a route just for that:
41
+
42
+ ```rb
43
+ plugin :direct_upload, presign: true
44
+ ```
45
+
46
+ This gives the endpoint a `GET /:storage/presign` route, which generates a
47
+ presign object and returns it as JSON:
48
+
49
+ ```rb
50
+ {
51
+ "url" => "https://shrine-testing.s3-eu-west-1.amazonaws.com",
52
+ "fields" => {
53
+ "key" => "b7d575850ba61b44c8a9ff889dfdb14d88cdc25f8dd121004c8",
54
+ "policy" => "eyJleHBpcmF0aW9uIjoiMjAxNS0QwMToxMToyOVoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJzaHJpbmUtdGVzdGluZyJ9LHsia2V5IjoiYjdkNTc1ODUwYmE2MWI0NGU3Y2M4YTliZmY4OGU5ZGZkYjE2NTQ0ZDk4OGNkYzI1ZjhkZDEyMTAwNGM4In0seyJ4LWFtei1jcmVkZW50aWFsIjoiQUtJQUlKRjU1VE1aWlk0NVVUNlEvMjAxNTEwMjQvZXUtd2VzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjIwMTUxMDI0VDAwMTEyOVoifV19",
55
+ "x-amz-credential" => "AKIAIJF55TMZYT6Q/20151024/eu-west-1/s3/aws4_request",
56
+ "x-amz-algorithm" => "AWS4-HMAC-SHA256",
57
+ "x-amz-date" => "20151024T001129Z",
58
+ "x-amz-signature" => "c1eb634f83f96b69bd675f535b3ff15ae184b102fcba51e4db5f4959b4ae26f4"
59
+ }
60
+ }
61
+ ```
62
+
63
+ You can use this data in a similar way as with static upload. See
64
+ the [example app] for how multiple file upload to S3 can be done using
65
+ [jQuery-File-Upload].
66
+
67
+ ## File hash
68
+
69
+ Once you've uploaded the file to S3, you need to create the representation of
70
+ the uploaded file which Shrine will understand. This is how a Shrine's uploaded
71
+ file looks like:
72
+
73
+ ```rb
74
+ {
75
+ "id" => "349234854924394",
76
+ "storage" => "cache",
77
+ "metadata" => {
78
+ "size" => 45461,
79
+ "filename" => "foo.jpg", # optional
80
+ "mime_type" => "image/jpeg", # optional
81
+ }
82
+ }
83
+ ```
84
+
85
+ The `id`, `storage` and `metadata.size` fields are required, and the rest of
86
+ the metadata is optional. You need to assign a JSON representation of this
87
+ hash to the model in place of the attachment.
88
+
89
+ ```rb
90
+ user.avatar = '{"id":"43244656","storage":"cache",...}'
91
+ ```
92
+
93
+ In a form you can assign this to an appropriate "hidden" field.
94
+
95
+ [`Aws::S3::PresignedPost`]: http://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Bucket.html#presigned_post-instance_method
96
+ [example app]: https://github.com/janko-m/shrine-example
97
+ [jQuery-File-Upload]: https://github.com/blueimp/jQuery-File-Upload
@@ -0,0 +1,79 @@
1
+ # Migrating to another storage
2
+
3
+ While your application is live in production and performing uploads, it may
4
+ happen that you decide you want to change your storage (the `:store`). Shrine
5
+ by design allows you to do that easily, with zero downtime, by deploying the
6
+ change in 2 phases.
7
+
8
+ ## Phase 1: Changing the storage
9
+
10
+ The first stage, add the desired storage to your registry, and make it your
11
+ current store (let's say that you're migrating from FileSystem to S3):
12
+
13
+ ```rb
14
+ Shrine.storages = {
15
+ cache: Shrine::Storage::FileSystem.new("public", subdirectory: "uploads/cache"),
16
+ store: Shrine::Storage::FileSystem.new("public", subdirectory: "uploads/store"),
17
+ new_store: Shrine::Storage::S3.new(**s3_options),
18
+ }
19
+
20
+ Shrine.plugin :default_storage, store: :new_store
21
+ ```
22
+
23
+ This will make already uploaded files stay uploaded on `:store`, and all new
24
+ files will be uploaded to `:new_store`.
25
+
26
+ ## Phase 2: Copying existing files
27
+
28
+ After you've deployed the previous change, it's time to copy all the existing
29
+ files to the new storage, and update the records. This is how you can do it
30
+ if you're using Sequel:
31
+
32
+ ```rb
33
+ Shrine.plugin :migration_helpers
34
+
35
+ User.paged_each do |user|
36
+ user.update_avatar do |avatar|
37
+ user.avatar_store.upload(avatar)
38
+ end
39
+ end
40
+
41
+ # Repeat for all other attachments and models
42
+ ```
43
+
44
+ Now your uploaded files are successfully copied to the new storage, so you
45
+ should be able to safely delete the old one.
46
+
47
+ ## Phase 3 and 4: Renaming new storage (optional)
48
+
49
+ The uploads will now be happening on the right storage, but if you would rather
50
+ rename `:new_store` back to `:store`, you can do two more phases. **First** you
51
+ need to deploy aliasing `:new_store` to `:store` (and make the default storage
52
+ be `:store` again):
53
+
54
+ ```rb
55
+ Shrine.storages = {
56
+ cache: Shrine::Storage::FileSystem.new("public", subdirectory: "uploads/cache"),
57
+ store: Shrine::Storage::S3.new(**s3_options),
58
+ }
59
+
60
+ Shrine.storages[:new_store] = Shrine.storages[:store]
61
+ ```
62
+
63
+ **Second**, you should rename the storage names on existing records. With
64
+ Sequel it would be something like:
65
+
66
+ ```rb
67
+ Shrine.plugin :migration_helpers
68
+
69
+ User.paged_each do |user|
70
+ user.update_avatar do |avatar|
71
+ avatar.to_json.gsub('new_store', 'store')
72
+ end
73
+ end
74
+
75
+ # Repeat for all other attachments and models
76
+ ```
77
+
78
+ Now everything should be in order and you should be able to remove the
79
+ `:new_store` alias.
@@ -0,0 +1,38 @@
1
+ # Regenerating versions
2
+
3
+ While your app is serving uploads in production, you may realize that you want
4
+ to change how your attachment's versions are generated. This means that, in
5
+ addition to changing you processing code, you also need to reprocess the
6
+ existing attachments. Depending on the magnitude and the nature of the change,
7
+ you can take different steps on doing that.
8
+
9
+ ## Regenerating a specific version
10
+
11
+ The simplest scenario is where you need to regenerate a specific version. After
12
+ you change your processing code, this is how you would regenerate a specific
13
+ version (in Sequel):
14
+
15
+ ```rb
16
+ Shrine.plugin :migration_helpers
17
+
18
+ User.paged_each do |user|
19
+ user.update_avatar do |avatar|
20
+ file = some_processing(avatar[:thumb].download)
21
+ avatar.merge(thumb: avatar[:thumb].replace(file))
22
+ end
23
+ end
24
+ ```
25
+
26
+ In a similar way you would add a new version or remove an existing one.
27
+
28
+ ## Regenerating all versions
29
+
30
+ If you made a lot of changes to versions, it might make sense to simply
31
+ regenerate all versions. You would typically use a "base" version to regenerate
32
+ the other versions from:
33
+
34
+ ```rb
35
+ User.paged_each do |user|
36
+ user.update(avatar: user.avatar[:original])
37
+ end
38
+ ```
@@ -0,0 +1,806 @@
1
+ require "shrine/version"
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ class Shrine
7
+ class Error < StandardError; end
8
+
9
+ # Raised when a file was not a valid IO.
10
+ class InvalidFile < Error
11
+ def initialize(io, missing_methods)
12
+ @io, @missing_methods = io, missing_methods
13
+ end
14
+
15
+ def message
16
+ "#{@io.inspect} is not a valid IO object (it doesn't respond to #{missing_methods_string})"
17
+ end
18
+
19
+ private
20
+
21
+ def missing_methods_string
22
+ @missing_methods.map { |m, args| "`#{m}(#{args.join(", ")})`" }.join(", ")
23
+ end
24
+ end
25
+
26
+ # Raised by storages in method `#clear!` when confirmation wasn't passed in.
27
+ class Confirm < Error
28
+ def message
29
+ "Are you sure you want to delete all files from the storage? (confirm with `clear!(:confirm)`)"
30
+ end
31
+ end
32
+
33
+ # Methods which an object has to respond to in order to be considered
34
+ # an IO object. Keys are method names, and values are arguments.
35
+ IO_METHODS = {
36
+ :read => [:length, :outbuf],
37
+ :eof? => [],
38
+ :rewind => [],
39
+ :size => [],
40
+ :close => [],
41
+ }
42
+
43
+ # Core class that represents a file uploaded to a storage. The instance
44
+ # methods for this class are added by Shrine::Plugins::Base::FileMethods, the
45
+ # class methods are added by Shrine::Plugins::Base::FileClassMethods.
46
+ class UploadedFile
47
+ @shrine_class = ::Shrine
48
+ end
49
+
50
+ # Core class which generates attachment-specific modules that are included in
51
+ # model classes. The instance methods for this class are added by
52
+ # Shrine::Plugins::Base::AttachmentMethods, the class methods are added by
53
+ # Shrine::Plugins::Base::AttachmentClassMethods.
54
+ class Attachment < Module
55
+ @shrine_class = ::Shrine
56
+ end
57
+
58
+ # Core class which handles attaching files on records. The instance methods
59
+ # for this class are added by Shrine::Plugins::Base::AttachmentMethods, the
60
+ # class methods are added by Shrine::Plugins::Base::AttachmentClassMethods.
61
+ class Attacher
62
+ @shrine_class = ::Shrine
63
+ end
64
+
65
+ @opts = {}
66
+ @storages = {}
67
+
68
+ # Module in which all Shrine plugins should be stored. Also contains logic
69
+ # for registering and loading plugins.
70
+ module Plugins
71
+ @plugins = {}
72
+
73
+ # If the registered plugin already exists, use it. Otherwise, require it
74
+ # and return it. This raises a LoadError if such a plugin doesn't exist,
75
+ # or a Shrine::Error if it exists but it does not register itself
76
+ # correctly.
77
+ def self.load_plugin(name)
78
+ unless plugin = @plugins[name]
79
+ require "shrine/plugins/#{name}"
80
+ raise Error, "plugin #{name} did not register itself correctly in Shrine::Plugins" unless plugin = @plugins[name]
81
+ end
82
+ plugin
83
+ end
84
+
85
+ # Register the given plugin with Shrine, so that it can be loaded using
86
+ # `Shrine.plugin` with a symbol. Should be used by plugin files. Example:
87
+ #
88
+ # Shrine::Plugins.register_plugin(:plugin_name, PluginModule)
89
+ def self.register_plugin(name, mod)
90
+ @plugins[name] = mod
91
+ end
92
+
93
+ # The base plugin for Shrine, implementing all default functionality.
94
+ # Methods are put into a plugin so future plugins can easily override
95
+ # them and call `super` to get the default behavior.
96
+ module Base
97
+ module ClassMethods
98
+ # Generic options for this class, plugins store their options here.
99
+ attr_reader :opts
100
+
101
+ # A hash of storages and their symbol identifiers.
102
+ attr_accessor :storages
103
+
104
+ # When inheriting Shrine, copy the instance variables into the subclass,
105
+ # and setup the subclasses for core classes.
106
+ def inherited(subclass)
107
+ subclass.instance_variable_set(:@opts, opts.dup)
108
+ subclass.opts.each do |key, value|
109
+ if value.is_a?(Enumerable) && !value.frozen?
110
+ subclass.opts[key] = value.dup
111
+ end
112
+ end
113
+ subclass.instance_variable_set(:@storages, storages.dup)
114
+
115
+ file_class = Class.new(self::UploadedFile)
116
+ file_class.shrine_class = subclass
117
+ subclass.const_set(:UploadedFile, file_class)
118
+
119
+ attachment_class = Class.new(self::Attachment)
120
+ attachment_class.shrine_class = subclass
121
+ subclass.const_set(:Attachment, attachment_class)
122
+
123
+ attacher_class = Class.new(self::Attacher)
124
+ attacher_class.shrine_class = subclass
125
+ subclass.const_set(:Attacher, attacher_class)
126
+ end
127
+
128
+ # Load a new plugin into the current class. A plugin can be a module
129
+ # which is used directly, or a symbol represented a registered plugin
130
+ # which will be required and then used. Returns nil.
131
+ #
132
+ # Shrine.plugin PluginModule
133
+ # Shrine.plugin :basic_authentication
134
+ def plugin(plugin, *args, &block)
135
+ plugin = Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
136
+ plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
137
+ include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
138
+ extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
139
+ self::UploadedFile.include(plugin::FileMethods) if defined?(plugin::FileMethods)
140
+ self::UploadedFile.extend(plugin::FileClassMethods) if defined?(plugin::FileClassMethods)
141
+ self::Attachment.include(plugin::AttachmentMethods) if defined?(plugin::AttachmentMethods)
142
+ self::Attachment.extend(plugin::AttachmentClassMethods) if defined?(plugin::AttachmentClassMethods)
143
+ self::Attacher.include(plugin::AttacherMethods) if defined?(plugin::AttacherMethods)
144
+ self::Attacher.extend(plugin::AttacherClassMethods) if defined?(plugin::AttacherClassMethods)
145
+ plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
146
+ nil
147
+ end
148
+
149
+ # Retrieves the storage specifies by the symbol/string, and raises an
150
+ # appropriate error if the storage is missing
151
+ def find_storage(name)
152
+ storages.each { |key, value| return value if key.to_s == name.to_s }
153
+ raise Error, "storage #{name.inspect} isn't registered on #{self}"
154
+ end
155
+
156
+ # Generates an instance of Shrine::Attachment to be included in the
157
+ # model class. Example:
158
+ #
159
+ # class User
160
+ # include Shrine[:avatar] # alias for `Shrine.attachment[:avatar]`
161
+ # end
162
+ def attachment(name)
163
+ self::Attachment.new(name)
164
+ end
165
+ alias [] attachment
166
+
167
+ # Instantiates a Shrine::UploadedFile from a JSON string or a hash, and
168
+ # optionally yields the returned objects (useful with versions). This
169
+ # is used internally by Shrine::Attacher, but it's also useful when you
170
+ # need to deserialize the uploaded file in background jobs.
171
+ #
172
+ # uploaded_file #=> #<Shrine::UploadedFile>
173
+ # json = uploaded_file.to_json #=> '{"storage":"cache","id":"...","metadata":{...}}'
174
+ # Shrine.uploaded_file(json) #=> #<Shrine::UploadedFile>
175
+ def uploaded_file(object, &block)
176
+ case object
177
+ when String
178
+ uploaded_file(JSON.parse(object), &block)
179
+ when Hash
180
+ uploaded_file(self::UploadedFile.new(object), &block)
181
+ when self::UploadedFile
182
+ object.tap { |f| yield(f) if block_given? }
183
+ else
184
+ raise Error, "cannot convert #{object.inspect} to a #{self}::UploadedFile"
185
+ end
186
+ end
187
+
188
+ # Delete a Shrine::UploadedFile.
189
+ #
190
+ # Shrine.delete(uploaded_file)
191
+ def delete(uploaded_file, context = {})
192
+ uploader_for(uploaded_file).delete(uploaded_file, context)
193
+ end
194
+
195
+ # Checks if the object is a valid IO by checking that it responds to
196
+ # `#read`, `#eof?`, `#rewind`, `#size` and `#close`, otherwise raises
197
+ # Shrine::InvalidFile.
198
+ def io!(io)
199
+ missing_methods = IO_METHODS.reject do |m, a|
200
+ io.respond_to?(m) && [a.count, -1].include?(io.method(m).arity)
201
+ end
202
+
203
+ raise InvalidFile.new(io, missing_methods) if missing_methods.any?
204
+ end
205
+
206
+ # Instantiates the Shrine uploader instance for this file.
207
+ def uploader_for(uploaded_file)
208
+ uploaders = storages.keys.map { |key| new(key) }
209
+ uploaders.find { |uploader| uploader.uploaded?(uploaded_file) }
210
+ end
211
+ end
212
+
213
+ module InstanceMethods
214
+ # The symbol that identifies the storage.
215
+ attr_reader :storage_key
216
+
217
+ # The storage object identified by #storage_key.
218
+ attr_reader :storage
219
+
220
+ # Accepts a storage symbol registered in `Shrine.storages`.
221
+ def initialize(storage_key)
222
+ @storage = self.class.find_storage(storage_key)
223
+ @storage_key = storage_key.to_sym
224
+ end
225
+
226
+ # The class-level options hash. This should probably not be modified
227
+ # at the instance level.
228
+ def opts
229
+ self.class.opts
230
+ end
231
+
232
+ # The main method for uploading files. Takes in an IO object and an
233
+ # optional context (used internally by Shrine::Attacher). It calls
234
+ # user-defined #process, and aferwards it calls #store.
235
+ def upload(io, context = {})
236
+ io = processed(io, context) || io
237
+ store(io, context)
238
+ end
239
+
240
+ # User is expected to perform processing inside of this method, and
241
+ # return the processed files. Returning nil signals that no proccessing
242
+ # has been done and that the original file should be used. When used
243
+ # with Shrine::Attachment, the context variable will hold the record,
244
+ # name of the attachment and the phase.
245
+ #
246
+ # class ImageUploader < Shrine
247
+ # def process(io, context)
248
+ # case context[:phase]
249
+ # when :cache
250
+ # # do processing
251
+ # when :store
252
+ # # do processing
253
+ # end
254
+ # end
255
+ # end
256
+ def process(io, context = {})
257
+ end
258
+
259
+ # Uploads the file and returns an instance of Shrine::UploadedFile. By
260
+ # default the location of the file is automatically generated by
261
+ # \#generate_location, but you can pass in `:location` to upload to
262
+ # a specific location.
263
+ #
264
+ # uploader.store(io, location: "custom/location.jpg")
265
+ def store(io, context = {})
266
+ _store(io, context)
267
+ end
268
+
269
+ # Checks if the storage identified with this instance uploaded the
270
+ # given file.
271
+ def uploaded?(uploaded_file)
272
+ uploaded_file.storage_key == storage_key.to_s
273
+ end
274
+
275
+ # Called by `Shrine.delete`.
276
+ def delete(uploaded_file, context = {})
277
+ _delete(uploaded_file, context)
278
+ uploaded_file
279
+ end
280
+
281
+ # Generates a unique location for the uploaded file, and preserves an
282
+ # optional extension.
283
+ def generate_location(io, context = {})
284
+ extension = File.extname(extract_filename(io).to_s)
285
+ basename = generate_uid(io)
286
+
287
+ basename + extension
288
+ end
289
+
290
+ # Extracts filename, size and MIME type from the file, which is later
291
+ # accessible through `UploadedFile#metadata`. When the uploaded file
292
+ # is later promoted, this metadata is simply copied over.
293
+ def extract_metadata(io, context = {})
294
+ {
295
+ "filename" => extract_filename(io),
296
+ "size" => extract_size(io),
297
+ "mime_type" => extract_mime_type(io),
298
+ }
299
+ end
300
+
301
+ # User-defined default URL for when a file is missing (called by
302
+ # `Attacher#url`).
303
+ def default_url(context)
304
+ end
305
+
306
+ private
307
+
308
+ # Extracts the filename from the IO using smart heuristics.
309
+ def extract_filename(io)
310
+ if io.respond_to?(:original_filename)
311
+ io.original_filename
312
+ elsif io.respond_to?(:path)
313
+ File.basename(io.path)
314
+ end
315
+ end
316
+
317
+ # Extracts the MIME type from the IO using smart heuristics.
318
+ def extract_mime_type(io)
319
+ if io.respond_to?(:mime_type)
320
+ io.mime_type
321
+ elsif io.respond_to?(:content_type)
322
+ io.content_type
323
+ end
324
+ end
325
+
326
+ # Extracts the filesize from the IO.
327
+ def extract_size(io)
328
+ io.size
329
+ end
330
+
331
+ # Called by #store. It first generates the location if it wasn't
332
+ # already provided with the `:location` option. Afterwards it extracts
333
+ # the metadata, stores the file, and returns a Shrine::UploadedFile.
334
+ def _store(io, context)
335
+ _enforce_io(io)
336
+ location = context[:location] || generate_location(io, context)
337
+ metadata = extract_metadata(io, context)
338
+
339
+ put(io, context.merge(location: location, metadata: metadata))
340
+
341
+ self.class::UploadedFile.new(
342
+ "id" => location,
343
+ "storage" => storage_key.to_s,
344
+ "metadata" => metadata,
345
+ )
346
+ end
347
+
348
+ # Removes the file. Called by `Shrine.delete`.
349
+ def _delete(uploaded_file, context)
350
+ remove(uploaded_file, context)
351
+ end
352
+
353
+ # Copies the file to the storage.
354
+ def put(io, context)
355
+ copy(io, context)
356
+ end
357
+
358
+ # Does the actual uploading, calling `#upload` on the storage.
359
+ def copy(io, context)
360
+ storage.upload(io, context[:location], context[:metadata])
361
+ end
362
+
363
+ # Some storages support moving, so we provide this method for plugins
364
+ # to use, but by default the file will be copied.
365
+ def move(io, context)
366
+ storage.move(io, context[:location], context[:metadata])
367
+ end
368
+
369
+ # Does the actual deletion, calls `UploadedFile#delete`.
370
+ def remove(uploaded_file, context)
371
+ uploaded_file.delete
372
+ end
373
+
374
+ # Calls #process and returns the processed files.
375
+ def processed(io, context)
376
+ process(io, context)
377
+ end
378
+
379
+ # Calls `Shrine#io!`.
380
+ def _enforce_io(io)
381
+ self.class.io!(io)
382
+ end
383
+
384
+ # Generates a UID to use in location for uploaded files.
385
+ def generate_uid(io)
386
+ SecureRandom.hex(30)
387
+ end
388
+ end
389
+
390
+ module AttachmentClassMethods
391
+ # Reference to the Shrine class related to this attachment class.
392
+ attr_accessor :shrine_class
393
+
394
+ # Since Attachment is anonymously subclassed when Shrine is subclassed,
395
+ # and then assigned to a constant of the Shrine subclass, make inspect
396
+ # reflect the likely name for the class.
397
+ def inspect
398
+ "#{shrine_class.inspect}::Attachment"
399
+ end
400
+ end
401
+
402
+ module AttachmentMethods
403
+ # Since Shrine::Attachment is a subclass of `Module`, this method
404
+ # generates a module, which should be included in a model class.
405
+ def initialize(name)
406
+ @name = name
407
+
408
+ # We store the attacher class so that it can be retrieved by the model
409
+ # at the instance level when instantiating the attacher. We use a
410
+ # class variable because (a) it can be accessed from the instance
411
+ # level without needing to create a class-level reader, and (b) we
412
+ # want it to be inherited when subclassing the model
413
+ class_variable_set(:"@@#{name}_attacher_class", shrine_class::Attacher)
414
+
415
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
416
+ def #{name}_attacher
417
+ @#{name}_attacher ||= @@#{name}_attacher_class.new(self, :#{name})
418
+ end
419
+
420
+ def #{name}=(value)
421
+ #{name}_attacher.assign(value)
422
+ end
423
+
424
+ def #{name}
425
+ #{name}_attacher.get
426
+ end
427
+
428
+ def #{name}_url(*args)
429
+ #{name}_attacher.url(*args)
430
+ end
431
+ RUBY
432
+ end
433
+
434
+ # Displays the attachment name.
435
+ #
436
+ # Shrine[:avatar] #=> #<Shrine::Attachment(avatar)>
437
+ def inspect
438
+ "#<#{self.class.inspect}(#{@name})>"
439
+ end
440
+
441
+ # Returns the Shrine class related to this attachment.
442
+ def shrine_class
443
+ self.class.shrine_class
444
+ end
445
+ end
446
+
447
+ module AttacherClassMethods
448
+ # Reference to the Shrine class related to this attacher class.
449
+ attr_accessor :shrine_class
450
+
451
+ # Since Attacher is anonymously subclassed when Shrine is subclassed,
452
+ # and then assigned to a constant of the Shrine subclass, make inspect
453
+ # reflect the likely name for the class.
454
+ def inspect
455
+ "#{shrine_class.inspect}::Attacher"
456
+ end
457
+
458
+ # Block that is executed in context of Shrine::Attacher during
459
+ # validation. Example:
460
+ #
461
+ # Shrine::Attacher.validate do
462
+ # if get.size > 5.megabytes
463
+ # errors << "is too big (max is 5 MB)"
464
+ # end
465
+ # end
466
+ def validate(&block)
467
+ shrine_class.opts[:validate] = block
468
+ end
469
+ end
470
+
471
+ module AttacherMethods
472
+ attr_reader :record, :name, :cache, :store, :errors
473
+
474
+ def initialize(record, name, cache: :cache, store: :store)
475
+ @record = record
476
+ @name = name
477
+ @cache = shrine_class.new(cache)
478
+ @store = shrine_class.new(store)
479
+ @errors = []
480
+ end
481
+
482
+ # Receives the attachment value from the form. If it receives a JSON
483
+ # string or a hash, it will assume this refrences an already cached
484
+ # file (e.g. when it persisted after validation errors).
485
+ # Otherwise it assumes that it's an IO object and caches it.
486
+ def assign(value)
487
+ if value.is_a?(String) || value.is_a?(Hash)
488
+ assign_cached(value) unless value == ""
489
+ else
490
+ uploaded_file = cache!(value, phase: :cache) if value
491
+ set(uploaded_file)
492
+ end
493
+ end
494
+
495
+ # Assigns a Shrine::UploadedFile, runs validation and schedules the
496
+ # old file for deletion.
497
+ def set(uploaded_file)
498
+ @old_attachment = get unless get == uploaded_file
499
+ _set(uploaded_file)
500
+ validate
501
+
502
+ get
503
+ end
504
+
505
+ # Retrieves the uploaded file from the record column.
506
+ def get
507
+ uploaded_file(read) if read
508
+ end
509
+
510
+ # Plugins can override this if they want something to be done on save.
511
+ def save
512
+ end
513
+
514
+ # Calls #promote if attached file is cached.
515
+ def _promote
516
+ promote(get) if promote?(get)
517
+ end
518
+
519
+ # Promotes a cached file to store, afterwards deleting the cached file.
520
+ # It does the promoting only if the cached file matches the current
521
+ # one. This check is done so that it can safely be used in background
522
+ # jobs in case the user quickly changes their mind and replaces the
523
+ # attachment before the old one was finished promoting.
524
+ def promote(cached_file)
525
+ stored_file = store!(cached_file, phase: :store)
526
+ unless changed?(cached_file)
527
+ update(stored_file)
528
+ else
529
+ delete!(stored_file, phase: :stored)
530
+ end
531
+ delete!(cached_file, phase: :cached)
532
+ end
533
+
534
+ # Deletes the attachment that was replaced. Typically this should be
535
+ # called after saving, to ensure that the file is deleted only after
536
+ # the record has been successfuly saved.
537
+ def replace
538
+ delete!(@old_attachment, phase: :replaced) if @old_attachment
539
+ @old_attachment = nil
540
+ end
541
+
542
+ # Deletes the attachment. Typically this should be called after
543
+ # destroying a record.
544
+ def destroy
545
+ delete!(get, phase: :destroyed) if get
546
+ end
547
+
548
+ # Returns the URL to the attached file (internally calls `#url` on the
549
+ # storage). If the attachment is missing, it calls
550
+ # `Shrine#default_url`. Forwards any URL options to the storage.
551
+ def url(**options)
552
+ if uploaded_file = get
553
+ uploaded_file.url(**options)
554
+ else
555
+ default_url(**options)
556
+ end
557
+ end
558
+
559
+ # Runs the validations defined by `Shrine.validate`.
560
+ def validate
561
+ errors.clear
562
+ instance_exec(&validate_block) if validate_block && get
563
+ end
564
+
565
+ def uploaded_file(*args, &block)
566
+ shrine_class.uploaded_file(*args, &block)
567
+ end
568
+
569
+ # Returns the Shrine class related to this attacher.
570
+ def shrine_class
571
+ self.class.shrine_class
572
+ end
573
+
574
+ private
575
+
576
+ # Assigns a cached file (refuses if the file is stored).
577
+ def assign_cached(value)
578
+ uploaded_file = uploaded_file(value)
579
+ set(uploaded_file) if cache.uploaded?(uploaded_file)
580
+ end
581
+
582
+ # Returns true if uploaded_file exists and is cached. If it's true,
583
+ # #promote will be called.
584
+ def promote?(uploaded_file)
585
+ uploaded_file && cache.uploaded?(uploaded_file)
586
+ end
587
+
588
+ # Sets and saves the uploaded file.
589
+ def update(uploaded_file)
590
+ _set(uploaded_file)
591
+ end
592
+
593
+ # Uploads the file to cache (calls `Shrine#upload`).
594
+ def cache!(io, phase:)
595
+ cache.upload(io, context.merge(phase: phase))
596
+ end
597
+
598
+ # Uploads the file to store (calls `Shrine#upload`).
599
+ def store!(io, phase:)
600
+ store.upload(io, context.merge(phase: phase))
601
+ end
602
+
603
+ # Deletes the file (calls `Shrine.delete`).
604
+ def delete!(uploaded_file, phase:)
605
+ shrine_class.delete(uploaded_file, context.merge(phase: phase))
606
+ end
607
+
608
+ # Delegates to `Shrine#default_url`.
609
+ def default_url(**options)
610
+ store.default_url(options.merge(context))
611
+ end
612
+
613
+ # The validation block provided by `Shrine.validate`.
614
+ def validate_block
615
+ shrine_class.opts[:validate]
616
+ end
617
+
618
+ # Checks if the uploaded file matches the written one.
619
+ def changed?(uploaded_file)
620
+ get != uploaded_file
621
+ end
622
+
623
+ # It dumps the UploadedFile to JSON and writes the result to the column.
624
+ def _set(uploaded_file)
625
+ write(uploaded_file ? uploaded_file.to_json : nil)
626
+ end
627
+
628
+ # It writes to record's `<attachment>_data` column.
629
+ def write(value)
630
+ record.send("#{name}_data=", value)
631
+ end
632
+
633
+ # It reads from the record's `<attachment>_data` column.
634
+ def read
635
+ value = record.send("#{name}_data")
636
+ value unless value.nil? || value.empty?
637
+ end
638
+
639
+ # The context that's sent to Shrine on upload and delete. It holds the
640
+ # record and the name of the attachment.
641
+ def context
642
+ @context ||= {name: name, record: record}.freeze
643
+ end
644
+ end
645
+
646
+ module FileClassMethods
647
+ # Reference to the Shrine class related to this uploaded file class.
648
+ attr_accessor :shrine_class
649
+
650
+ # Since UploadedFile is anonymously subclassed when Shrine is subclassed,
651
+ # and then assigned to a constant of the Shrine subclass, make inspect
652
+ # reflect the likely name for the class.
653
+ def inspect
654
+ "#{shrine_class.inspect}::UploadedFile"
655
+ end
656
+ end
657
+
658
+ module FileMethods
659
+ # The ID of the uploaded file, which holds the location of the actual
660
+ # file on the storage
661
+ attr_reader :id
662
+
663
+ # The storage key as a string.
664
+ attr_reader :storage_key
665
+
666
+ # A hash of metadata, returned from `Shrine#extract_metadata`.
667
+ attr_reader :metadata
668
+
669
+ # The entire data hash which identifies this uploaded file.
670
+ attr_reader :data
671
+
672
+ def initialize(data)
673
+ @data = data
674
+ @id = data.fetch("id")
675
+ @storage_key = data.fetch("storage")
676
+ @metadata = data.fetch("metadata")
677
+
678
+ storage # ensure storage exists
679
+ end
680
+
681
+ # The filename that was extracted from the original file.
682
+ def original_filename
683
+ metadata.fetch("filename")
684
+ end
685
+
686
+ # The extension derived from `#original_filename`.
687
+ def extension
688
+ extname = File.extname(id)
689
+ extname[1..-1] unless extname.empty?
690
+ end
691
+
692
+ # The filesize of the original file.
693
+ def size
694
+ Integer(metadata.fetch("size"))
695
+ end
696
+
697
+ # The MIME type of the original file.
698
+ def mime_type
699
+ metadata.fetch("mime_type")
700
+ end
701
+
702
+ # Part of Shrine::UploadedFile's complying to the IO interface. It
703
+ # delegates to the internally downloaded file.
704
+ def read(*args)
705
+ io.read(*args)
706
+ end
707
+
708
+ # Part of Shrine::UploadedFile's complying to the IO interface. It
709
+ # delegates to the internally downloaded file.
710
+ def eof?
711
+ io.eof?
712
+ end
713
+
714
+ # Part of Shrine::UploadedFile's complying to the IO interface. It
715
+ # delegates to the internally downloaded file.
716
+ def close
717
+ io.close
718
+ end
719
+
720
+ # Part of Shrine::UploadedFile's complying to the IO interface. It
721
+ # delegates to the internally downloaded file.
722
+ def rewind
723
+ io.rewind
724
+ end
725
+
726
+ # Calls `#url` on the storage, forwarding any options.
727
+ def url(**options)
728
+ storage.url(id, **options)
729
+ end
730
+
731
+ # Calls `#exists?` on the storage, which checks that the file exists.
732
+ def exists?
733
+ storage.exists?(id)
734
+ end
735
+
736
+ # Calls `#download` on the storage, which downloads the file to disk.
737
+ def download
738
+ storage.download(id)
739
+ end
740
+
741
+ # Uploads a new file to this file's location and returns it.
742
+ def replace(io, context = {})
743
+ uploader.upload(io, context.merge(location: id))
744
+ end
745
+
746
+ # Calls `#delete` on the storage, which deletes the remote file.
747
+ def delete
748
+ storage.delete(id)
749
+ end
750
+
751
+ # Added as a Ruby conversion method. It typically downloads the file.
752
+ def to_io
753
+ io
754
+ end
755
+
756
+ # Serializes the uploaded file to JSON, suitable for storing in the
757
+ # column or passing to a background job.
758
+ def to_json(*args)
759
+ data.to_json(*args)
760
+ end
761
+
762
+ # Conform to ActiveSupport's JSON interface.
763
+ def as_json(*args)
764
+ data
765
+ end
766
+
767
+ # Two uploaded files are equal if they're uploaded to the same storage
768
+ # and they have the same #id.
769
+ def ==(other)
770
+ other.is_a?(self.class) &&
771
+ self.id == other.id &&
772
+ self.storage_key == other.storage_key
773
+ end
774
+ alias eql? ==
775
+
776
+ def hash
777
+ [id, storage_key].hash
778
+ end
779
+
780
+ # The instance of `Shrine` with the corresponding storage.
781
+ def uploader
782
+ @uploader ||= shrine_class.new(storage_key)
783
+ end
784
+
785
+ # The storage class this file was uploaded to.
786
+ def storage
787
+ uploader.storage
788
+ end
789
+
790
+ # Returns the Shrine class related to this uploaded file.
791
+ def shrine_class
792
+ self.class.shrine_class
793
+ end
794
+
795
+ private
796
+
797
+ def io
798
+ @io ||= storage.open(id)
799
+ end
800
+ end
801
+ end
802
+ end
803
+
804
+ extend Plugins::Base::ClassMethods
805
+ plugin Plugins::Base
806
+ end