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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +663 -0
- data/doc/creating_plugins.md +100 -0
- data/doc/creating_storages.md +108 -0
- data/doc/direct_s3.md +97 -0
- data/doc/migrating_storage.md +79 -0
- data/doc/regenerating_versions.md +38 -0
- data/lib/shrine.rb +806 -0
- data/lib/shrine/plugins/activerecord.rb +89 -0
- data/lib/shrine/plugins/background_helpers.rb +148 -0
- data/lib/shrine/plugins/cached_attachment_data.rb +47 -0
- data/lib/shrine/plugins/data_uri.rb +93 -0
- data/lib/shrine/plugins/default_storage.rb +39 -0
- data/lib/shrine/plugins/delete_invalid.rb +25 -0
- data/lib/shrine/plugins/determine_mime_type.rb +119 -0
- data/lib/shrine/plugins/direct_upload.rb +274 -0
- data/lib/shrine/plugins/dynamic_storage.rb +57 -0
- data/lib/shrine/plugins/hooks.rb +123 -0
- data/lib/shrine/plugins/included.rb +48 -0
- data/lib/shrine/plugins/keep_files.rb +54 -0
- data/lib/shrine/plugins/logging.rb +158 -0
- data/lib/shrine/plugins/migration_helpers.rb +61 -0
- data/lib/shrine/plugins/moving.rb +75 -0
- data/lib/shrine/plugins/multi_delete.rb +47 -0
- data/lib/shrine/plugins/parallelize.rb +62 -0
- data/lib/shrine/plugins/pretty_location.rb +32 -0
- data/lib/shrine/plugins/recache.rb +36 -0
- data/lib/shrine/plugins/remote_url.rb +127 -0
- data/lib/shrine/plugins/remove_attachment.rb +59 -0
- data/lib/shrine/plugins/restore_cached.rb +36 -0
- data/lib/shrine/plugins/sequel.rb +94 -0
- data/lib/shrine/plugins/store_dimensions.rb +82 -0
- data/lib/shrine/plugins/validation_helpers.rb +168 -0
- data/lib/shrine/plugins/versions.rb +177 -0
- data/lib/shrine/storage/file_system.rb +165 -0
- data/lib/shrine/storage/linter.rb +94 -0
- data/lib/shrine/storage/s3.rb +118 -0
- data/lib/shrine/version.rb +14 -0
- data/shrine.gemspec +46 -0
- 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
|
+
```
|
data/doc/direct_s3.md
ADDED
@@ -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
|
+
```
|
data/lib/shrine.rb
ADDED
@@ -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
|