saviour 0.5.10 → 0.5.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -1
- data/Gemfile +1 -1
- data/README.md +939 -346
- data/gemfiles/{5.0.gemfile → 5.2.gemfile} +2 -2
- data/lib/saviour.rb +1 -0
- data/lib/saviour/file.rb +11 -12
- data/lib/saviour/integrator.rb +30 -5
- data/lib/saviour/life_cycle.rb +18 -2
- data/lib/saviour/model.rb +1 -0
- data/lib/saviour/read_only_file.rb +35 -0
- data/lib/saviour/version.rb +1 -1
- data/saviour.gemspec +2 -2
- data/spec/feature/{concurrent_processors_spec.rb → concurrency_spec.rb} +49 -1
- data/spec/feature/crud_workflows_spec.rb +26 -2
- data/spec/feature/dirty_spec.rb +70 -8
- data/spec/feature/remove_attachment_spec.rb +50 -3
- data/spec/feature/stash_spec.rb +1 -2
- data/spec/models/file_spec.rb +6 -16
- data/spec/support/active_record_asserts.rb +10 -1
- metadata +9 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8506a47d7861dc92875e3dca040bc958ef442f4622464c4604545b7d2d255e36
|
4
|
+
data.tar.gz: 483e8eb3fe97c38feb64cdc2e7f7cc20580070e533759fc991fddd4ecb3ea054
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b751a08547b441ebebe4483d880ddd7c5a302c1f8eb0c040a65b282ab7d9a9ce0a61c7a54d677098c99a91f67f4b6bf81b58fe4e453807b02514490d85f95e09
|
7
|
+
data.tar.gz: e0157df1f371809d1d8894bcfe6af390a3ce6a8ab649e16b7a6b9bd982f17ff95c7786ddfd330a9654625c6956cf2f029e68c9a6a8cc858f5950206317546bc6
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -4,569 +4,1162 @@
|
|
4
4
|
|
5
5
|
# Saviour
|
6
6
|
|
7
|
-
|
8
|
-
|
7
|
+
Saviour is a tool to help you manage files attached to Active Record models. It tries to be minimal about the
|
8
|
+
use cases it covers, but with a deep and complete coverage on the ones it does. For example, it offers
|
9
|
+
no support for image manipulation, but it does implement dirty tracking and transactional-aware behavior.
|
10
|
+
|
11
|
+
It also tries to have a flexible design, so that additional features can be added by the user on top of it.
|
12
|
+
You can see an example of such typical features on the [FAQ section at the end of this document](#faq).
|
13
|
+
|
14
|
+
|
15
|
+
## Motivation
|
16
|
+
|
17
|
+
This project started in 2015 as an attempt to replace Carrierwave. Since then other solutions have appeared
|
18
|
+
to solve the same problem, like [shrine](https://github.com/shrinerb/shrine), [refile](https://github.com/refile/refile)
|
19
|
+
and even more recently rails own solution [activestorage](https://github.com/rails/rails/tree/master/activestorage).
|
20
|
+
|
21
|
+
The main difference between those solutions and Saviour is about the broadness and scope of the problem
|
22
|
+
that wants to be solved.
|
23
|
+
|
24
|
+
They offer a complete out-of-the-box solution that covers many different needs:
|
25
|
+
image management, caching of files for seamless integration with html forms, direct uploads to s3, metadata
|
26
|
+
extraction, background jobs integration or support for different ORMs are some of the features you can find on
|
27
|
+
those libraries.
|
28
|
+
|
29
|
+
If you need those functionalities and they suit your needs, they can be perfect solutions for you.
|
30
|
+
|
31
|
+
The counterpart, however, is that they have more dependencies and, as they cover a broader spectrum of
|
32
|
+
use cases, they tend to impose more conventions that are expected to be followed as is. If you don't want,
|
33
|
+
or can't follow some of those conventions then you're out of luck.
|
34
|
+
|
35
|
+
Saviour provides a battle-tested infrastructure for storing files following an AR model
|
36
|
+
life-cycle which can be easily extended to suit your custom needs.
|
37
|
+
|
38
|
+
|
39
|
+
|
40
|
+
## Installation
|
41
|
+
|
42
|
+
Add this line to your application's Gemfile:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
gem 'saviour'
|
46
|
+
```
|
47
|
+
|
48
|
+
And then execute:
|
49
|
+
|
50
|
+
$ bundle
|
9
51
|
|
10
52
|
|
11
53
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
12
54
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
13
55
|
|
14
|
-
|
15
|
-
- [
|
16
|
-
- [
|
17
|
-
- [
|
18
|
-
- [
|
19
|
-
|
20
|
-
|
21
|
-
- [
|
22
|
-
- [
|
23
|
-
|
24
|
-
|
25
|
-
- [
|
26
|
-
- [
|
27
|
-
- [
|
28
|
-
|
29
|
-
- [
|
30
|
-
- [Validations](#validations)
|
31
|
-
- [
|
56
|
+
- [Quick start](#quick-start)
|
57
|
+
- [General Usage](#general-usage)
|
58
|
+
- [Api on attachment](#api-on-attachment)
|
59
|
+
- [Additional api on the model](#additional-api-on-the-model)
|
60
|
+
- [Storages](#storages)
|
61
|
+
- [Local Storage](#local-storage)
|
62
|
+
- [S3 Storage](#s3-storage)
|
63
|
+
- [Uploader classes](#uploader-classes)
|
64
|
+
- [store_dir](#store_dir)
|
65
|
+
- [Processors](#processors)
|
66
|
+
- [halt_process](#halt_process)
|
67
|
+
- [Versions](#versions)
|
68
|
+
- [Transactional behavior](#transactional-behavior)
|
69
|
+
- [Concurrency](#concurrency)
|
70
|
+
- [stash](#stash)
|
71
|
+
- [Dirty tracking](#dirty-tracking)
|
72
|
+
- [AR Validations](#ar-validations)
|
73
|
+
- [Introspection](#introspection)
|
74
|
+
- [Extras & Advance usage](#extras--advance-usage)
|
75
|
+
- [Skip processors](#skip-processors)
|
76
|
+
- [Testing](#testing)
|
77
|
+
- [Sources: url and string](#sources-url-and-string)
|
78
|
+
- [Custom Storages](#custom-storages)
|
79
|
+
- [Bypassing Saviour](#bypassing-saviour)
|
80
|
+
- [Bypass example: Nested Cloning](#bypass-example-nested-cloning)
|
32
81
|
- [FAQ](#faq)
|
33
|
-
- [
|
34
|
-
- [
|
82
|
+
- [how to reuse code in your app, attachment with defaults](#how-to-reuse-code-in-your-app-attachment-with-defaults)
|
83
|
+
- [How to manage file removal from forms](#how-to-manage-file-removal-from-forms)
|
84
|
+
- [How to extract metadata from files](#how-to-extract-metadata-from-files)
|
85
|
+
- [How to process files in background / delayed](#how-to-process-files-in-background--delayed)
|
35
86
|
- [How to recreate versions](#how-to-recreate-versions)
|
36
|
-
- [
|
37
|
-
|
38
|
-
- [Processing in background](#processing-in-background)
|
87
|
+
- [How to digest the filename](#how-to-digest-the-filename)
|
88
|
+
- [License](#license)
|
39
89
|
|
40
90
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
41
91
|
|
42
92
|
|
43
|
-
##
|
93
|
+
## Quick start
|
44
94
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
95
|
+
First, you'll need to configure Saviour to indicate what type of storage you'll want to use. For example,
|
96
|
+
to use local storage:
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
# config/initializers/saviour.rb
|
50
100
|
|
101
|
+
Saviour::Config.storage = Saviour::LocalStorage.new(
|
102
|
+
local_prefix: Rails.root.join('public/system/uploads/'),
|
103
|
+
public_url_prefix: "https://mywebsite.com/system/"
|
104
|
+
)
|
105
|
+
```
|
51
106
|
|
52
|
-
|
107
|
+
A local storage will persist the files on the server running the ruby code and will require settings to
|
108
|
+
indicate precisely where to store those files locally and how to build a public url to them. Those settings
|
109
|
+
depend on your server and deployment configurations. Saviour ships with local storage and Amazon's S3 storage
|
110
|
+
capabilities, see the section on [Storages](#storages) for more details.
|
53
111
|
|
54
|
-
|
55
|
-
|
112
|
+
Saviour will also require a text column for each attachment in an ActiveRecord model. This column will be used to
|
113
|
+
persist a file's "path" across the storage. For example:
|
56
114
|
|
115
|
+
```ruby
|
116
|
+
create_table "users" do |t|
|
117
|
+
# other columns...
|
118
|
+
t.text "avatar"
|
119
|
+
end
|
57
120
|
```
|
58
|
-
|
121
|
+
|
122
|
+
Then include the mixin `Saviour::Model` in your AR model and declare the attachment:
|
123
|
+
|
124
|
+
```ruby
|
125
|
+
class User < ApplicationRecord
|
59
126
|
include Saviour::Model
|
60
127
|
|
61
|
-
|
62
|
-
|
128
|
+
attach_file(:avatar) do
|
129
|
+
store_dir { "uploads/avatars/#{model.id}/" }
|
130
|
+
end
|
63
131
|
end
|
132
|
+
```
|
64
133
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
process :resize, width: 500, height: 500
|
134
|
+
Declaring a `store_dir` is mandatory and indicates at what base path the assigned files must be stored. More
|
135
|
+
on this later at the [Uploaders section](#uploader-classes).
|
69
136
|
|
70
|
-
|
71
|
-
process :resize, width: 100, height: 100
|
72
|
-
end
|
137
|
+
Now you can use it:
|
73
138
|
|
74
|
-
|
75
|
-
|
76
|
-
|
139
|
+
```ruby
|
140
|
+
user = User.create! avatar: File.open("/path/to/cowboy.jpg")
|
141
|
+
user.avatar.read # => binary contents
|
77
142
|
|
78
|
-
|
79
|
-
|
143
|
+
# Url generation depends on how the storage is configured
|
144
|
+
user.avatar.url # => "https://mywebsite.com/system/uploads/avatars/1/cowboy.jpg"
|
80
145
|
|
81
|
-
|
82
|
-
|
83
|
-
end
|
146
|
+
# Using local storage, the persisted column will have the path to the file
|
147
|
+
user[:avatar] # => "uploads/avatars/1/cowboy.jpg"
|
84
148
|
```
|
85
149
|
|
86
|
-
In this example we have posts that have an image. That image will be stored in a path like `/default/path/<id>/image`
|
87
|
-
and also a resize operation will be performed before persisting the file.
|
88
150
|
|
89
|
-
|
90
|
-
|
151
|
+
### General Usage
|
152
|
+
|
153
|
+
You can assign to an attachment any object that responds to `read`. This includes `File`, `StringIO` and many others.
|
154
|
+
|
155
|
+
The filename given to the file will be obtained by following this process:
|
156
|
+
|
157
|
+
- First, trying to call `original_filename` on the given object.
|
158
|
+
- Second, trying to call `filename` on the given object.
|
159
|
+
- Finally, if that object responds to `path`, it will be extracted as the basename of that path.
|
160
|
+
|
161
|
+
If none of that works, a random filename will be assigned.
|
91
162
|
|
92
|
-
|
93
|
-
|
163
|
+
The actual storing of the file and any possible related processing (more on this [later](#processors)) will
|
164
|
+
happen on after save, not on assignation. You can assign and re-assign different values to an attachment at no
|
165
|
+
cost.
|
94
166
|
|
95
|
-
Here the resize manipulation is done in-memory, but there're also a way to handle manipulations done at the file level if
|
96
|
-
you need to use external binaries like imagemagick, image optimization tools (pngquant, jpegotim, etc...) or others.
|
97
167
|
|
168
|
+
#### Api on attachment
|
98
169
|
|
99
|
-
|
170
|
+
Given the previous example of a User with an avatar attachment, the following methods are available to you on the attachment object:
|
100
171
|
|
101
|
-
`
|
102
|
-
|
172
|
+
- `user.avatar.present?` && `.blank?`: Indicates if the attachment has an associated file or not, even if it has not been persisted yet. This methods allow you for a transparent use of rails `validates_presence_of :avatar`, as the object responds to `blank?`.
|
173
|
+
- `user.avatar.persisted?`: Indicates if the attachment has an associated file and this file is persisted. Is false after assignation and before save.
|
174
|
+
- `user.avatar?`: Same as `user.avatar.present?`
|
175
|
+
- `user.avatar.exists?`: If the attachment is `persisted?`, it checks with the storage to verify the existence of the associated path. Use it to check for situations where the database has a persisted path but the storage may not have the file, due to any other reasons (direct manipulation by other means).
|
176
|
+
- `user.avatar.with_copy {|f| ... }`: Utility method that fetches the file and gives it to you in the form of a `Tempfile`. Will forward the return value of your block. The tempfile will be cleaned up on block termination.
|
177
|
+
- `user.avatar.read`: Returns binary raw contents of the stored file.
|
178
|
+
- `user.avatar.url`: Returns the url to the stored file, based on the storage configurations.
|
179
|
+
- `user.avatar.reload`: If the contents of the storage were directly manipulated, you can use this method to force a reload of the attachment state from the storage.
|
180
|
+
- `user.avatar.filename`: Returns the filename of the stored file.
|
181
|
+
- `user.avatar.persisted_path`: If persisted, returns the path of the file as stored in the storage, otherwise nil. It's the same as the db column value.
|
182
|
+
- `user.avatar.changed?`: Returns true/false if the attachment has been assigned but not yet saved.
|
183
|
+
|
184
|
+
Usage example:
|
103
185
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
- url
|
111
|
-
- changed?
|
112
|
-
- filename
|
113
|
-
- with_copy
|
114
|
-
- blank?
|
186
|
+
```ruby
|
187
|
+
user = User.new
|
188
|
+
|
189
|
+
user.avatar? # => false
|
190
|
+
user.avatar.present? # => false
|
191
|
+
user.avatar.blank? # => true
|
115
192
|
|
116
|
-
|
117
|
-
about Sources abstraction for further info.
|
193
|
+
user.avatar.read # => nil, same for #url, #filename, #persisted_path
|
118
194
|
|
119
|
-
|
120
|
-
is channeled with the uploader first to handle processings. `url` is just an alias for `public_url`.
|
195
|
+
user.avatar = File.open("image.jpg")
|
121
196
|
|
122
|
-
|
123
|
-
|
197
|
+
user.avatar.changed? # => true
|
198
|
+
user.avatar? # => true, same as #present?
|
199
|
+
user.avatar.persisted? # => false
|
124
200
|
|
125
|
-
|
201
|
+
user.avatar.url # => nil, not yet persisted
|
202
|
+
user.avatar.exists? # => false, not yet persisted
|
203
|
+
user.avatar.filename # => "image.jpg"
|
204
|
+
user.avatar.read # => nil, not yet persisted
|
126
205
|
|
127
|
-
|
128
|
-
default rails validations like `validates_presence_of`.
|
206
|
+
user.avatar.with_copy # => nil, not yet persisted
|
129
207
|
|
130
|
-
|
131
|
-
passed to the method with that Tempfile. Will clean afterwards.
|
208
|
+
user.save!
|
132
209
|
|
133
|
-
|
210
|
+
user.avatar.changed? # => false
|
211
|
+
user.avatar? # => true
|
212
|
+
user.avatar.exists? # => true
|
213
|
+
user.avatar.persisted? # => true
|
134
214
|
|
215
|
+
user.avatar.read # => bytecontents
|
216
|
+
user.avatar.url # => "https://somedomain.com/path/image.jpg"
|
217
|
+
user.avatar.with_copy # => yields a tempfile with the image
|
218
|
+
user.avatar.read # => bytecontents
|
135
219
|
```
|
136
|
-
|
137
|
-
|
220
|
+
|
221
|
+
|
222
|
+
#### Additional api on the model
|
223
|
+
|
224
|
+
When you declare an attachment in an AR model, the model is extended with:
|
225
|
+
|
226
|
+
- `#dup`: The `dup` method over the AR instance will also take care of dupping any possible attachment with associated files if any. If the new instance returned by dup is saved, the attachments will be saved as well normally, generating a copy of the files present on the original instance.
|
227
|
+
|
228
|
+
- `#remove_<attached_as>!`: This new method will be added for each attachment. For example, `user.remove_avatar!`. Use this method to remove the associated file.
|
229
|
+
|
230
|
+
Usage example:
|
231
|
+
|
232
|
+
```ruby
|
233
|
+
user = User.create! avatar: File.open("image.jpg")
|
234
|
+
|
235
|
+
user.avatar.url # => "https://somedomain.com/uploads_path/users/1/avatar/image.jpg"
|
236
|
+
|
237
|
+
new_user = user.dup
|
238
|
+
new_user.save!
|
239
|
+
|
240
|
+
new_user.avatar.url # => "https://somedomain.com/uploads_path/users/2/avatar/image.jpg"
|
241
|
+
|
242
|
+
new_user.remove_avatar!
|
243
|
+
new_user.avatar? # => false
|
138
244
|
```
|
139
245
|
|
140
|
-
You can also get the `File` instance of version by using an argument matching the version name:
|
141
246
|
|
247
|
+
|
248
|
+
### Storages
|
249
|
+
|
250
|
+
Storages are the Saviour's components responsible for file persistence. Local storage and Amazon's S3 storage
|
251
|
+
are available by default, but more can be built, as they are designed as independent components and any class
|
252
|
+
that follows the expected public api can be used as one. More on this on the [Custom storage section](custom-storages).
|
253
|
+
|
254
|
+
We'll review now how to use the two provided storages.
|
255
|
+
|
256
|
+
#### Local Storage
|
257
|
+
|
258
|
+
You can use this storage to store files in the local machine running the ruby code. Example:
|
259
|
+
|
260
|
+
```ruby
|
261
|
+
# config/initializers/saviour.rb
|
262
|
+
|
263
|
+
Saviour::Config.storage = Saviour::LocalStorage.new(
|
264
|
+
local_prefix: Rails.root.join('public/system/uploads/'),
|
265
|
+
public_url_prefix: "http://mydomain.com/uploads"
|
266
|
+
)
|
142
267
|
```
|
143
|
-
|
144
|
-
|
145
|
-
|
268
|
+
|
269
|
+
The `local_prefix` is the base prefix under which the storage will store files in the
|
270
|
+
machine. You need to configure this accordingly to your use case and deployment strategies, for example, for rails
|
271
|
+
and capistrano with default settings you'll have to store the files under `Rails.root.join("public/system")`,
|
272
|
+
as this is by default the shared directory between deployments.
|
273
|
+
|
274
|
+
The `public_url_prefix` is the base prefix to build the public endpoint from which you'll serve the assets.
|
275
|
+
Same as before, you'll need to configure this accordingly to your deployment specifics.
|
276
|
+
|
277
|
+
You can also assign a Proc instead of a String to dynamically calculate the value, useful when you have multiple
|
278
|
+
asset hosts:
|
279
|
+
|
280
|
+
`public_url_prefix: -> { https://media-#{rand(4)}.mywebsite.com/system/uploads/" }`
|
281
|
+
|
282
|
+
This storage will take care of removing folders after they become empty.
|
283
|
+
|
284
|
+
The optional extra argument `permissions` will allow you to set what permissions the files should have locally.
|
285
|
+
This value defaults to '0644' and can be changed when creating the storage instance:
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
Saviour::Config.storage = Saviour::LocalStorage.new(
|
289
|
+
local_prefix: Rails.root.join('public/system/uploads/'),
|
290
|
+
public_url_prefix: "http://mydomain.com/uploads",
|
291
|
+
permissions: '0600'
|
292
|
+
)
|
146
293
|
```
|
147
294
|
|
148
|
-
|
295
|
+
#### S3 Storage
|
296
|
+
|
297
|
+
This storage will store files on Amazon S3, using the `aws-sdk-s3` gem. Example:
|
149
298
|
|
299
|
+
```ruby
|
300
|
+
Saviour::Config.storage = Saviour::S3Storage.new(
|
301
|
+
bucket: "my-bucket-name",
|
302
|
+
aws_access_key_id: "stub",
|
303
|
+
aws_secret_access_key: "stub",
|
304
|
+
region: "my-region",
|
305
|
+
public_url_prefix: "https://s3-eu-west-1.amazonaws.com/my-bucket/"
|
306
|
+
)
|
150
307
|
```
|
151
|
-
|
152
|
-
|
153
|
-
|
308
|
+
|
309
|
+
The first 4 options (`bucket`, `aws_access_key_id`, `aws_secret_access_key` and `region`) are required for the
|
310
|
+
connection and usage of your s3 bucket.
|
311
|
+
|
312
|
+
The `public_url_prefix` is the base prefix to build the public endpoint from which the files are available.
|
313
|
+
Normally you'll set it as in the example provided, or you can also change it accordingly to any CDN you may be
|
314
|
+
using.
|
315
|
+
|
316
|
+
You can also assign a Proc instead of a String to dynamically calculate the value, which is useful when you have multiple
|
317
|
+
asset hosts:
|
318
|
+
|
319
|
+
`public_url_prefix: -> { https://media-#{rand(4)}.mywebsite.com/system/uploads/" }`
|
320
|
+
|
321
|
+
The optional argument `create_options` can be given to establishing extra parameters to use when creating files. For
|
322
|
+
example you might want to set up a large cache control value so that the files become cacheable:
|
323
|
+
|
324
|
+
```ruby
|
325
|
+
create_options: {
|
326
|
+
cache_control: 'max-age=31536000' # 1 year
|
327
|
+
}
|
154
328
|
```
|
155
329
|
|
156
|
-
|
330
|
+
Those options will be forwarded directly to aws-sdk, you can see the complete reference here:
|
331
|
+
|
332
|
+
https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_object-instance_method
|
157
333
|
|
158
|
-
|
159
|
-
|
160
|
-
|
334
|
+
Currently, there's no support for different create options on a per-file basis. All stored files will be created
|
335
|
+
using the same options. If you want a public access on those files, you can make them public with a general
|
336
|
+
rule at the bucket level or using the `acl` create option:
|
161
337
|
|
338
|
+
```ruby
|
339
|
+
create_options: {
|
340
|
+
acl: 'public-read'
|
341
|
+
}
|
162
342
|
```
|
163
|
-
def write(contents, path)
|
164
|
-
end
|
165
343
|
|
166
|
-
|
344
|
+
NOTE: Be aware that S3 has a limit of 1024 bytes for the keys (paths) used. Trying to store a file with a
|
345
|
+
larger path will result in a `Saviour::KeyTooLarge` exception.
|
346
|
+
|
347
|
+
|
348
|
+
### Uploader classes
|
349
|
+
|
350
|
+
Uploader classes are responsible to make changes to an attachment byte contents or filename, as well as indicating
|
351
|
+
what base path that file should have.
|
352
|
+
|
353
|
+
An uploader class can be provided explicitly, for example:
|
354
|
+
|
355
|
+
```ruby
|
356
|
+
# app/uploaders/post_image_uploader.rb
|
357
|
+
class PostImageUploader < Saviour::BaseUploader
|
358
|
+
store_dir { "uploads/posts/images/#{model.id}/" }
|
167
359
|
end
|
168
360
|
|
169
|
-
|
361
|
+
# app/models/post.rb
|
362
|
+
class Post < ApplicationRecord
|
363
|
+
include Saviour::Model
|
364
|
+
|
365
|
+
attach_file :image, PostImageUploader
|
170
366
|
end
|
367
|
+
```
|
368
|
+
|
369
|
+
Or you can also provide a `&block` to the `attach_file` method to declare the uploader class implicitly. This
|
370
|
+
syntax is usually more convenient if you don't have a lot of code in your uploaders:
|
171
371
|
|
172
|
-
|
372
|
+
```ruby
|
373
|
+
class Post < ApplicationRecord
|
374
|
+
include Saviour::Model
|
375
|
+
|
376
|
+
attach_file :image do
|
377
|
+
store_dir { "uploads/posts/images/#{model.id}/" }
|
378
|
+
end
|
173
379
|
end
|
174
380
|
```
|
175
381
|
|
176
|
-
The convention here is that a file consist of a raw content and a path representing its location within the underlying
|
177
|
-
persistence layer.
|
178
382
|
|
179
|
-
|
383
|
+
#### store_dir
|
180
384
|
|
181
|
-
|
182
|
-
|
385
|
+
Declaring a `store_dir` is mandatory for each uploader class. It can be provided directly as a block or as a symbol,
|
386
|
+
in which case it has to match with a method you define on the uploader class.
|
387
|
+
|
388
|
+
Its returning value must be a string representing the base path under which the files will be stored.
|
389
|
+
|
390
|
+
At runtime the model is available as `model`, and the name of the attachment as `attached_as`. For example:
|
391
|
+
|
392
|
+
```ruby
|
393
|
+
class PostImageUploader < Saviour::BaseUploader
|
394
|
+
store_dir { "uploads/posts/images/#{model.id}/" }
|
395
|
+
|
396
|
+
# or
|
397
|
+
store_dir { "uploads/posts/#{model.id}/#{attached_as}" }
|
398
|
+
|
399
|
+
# or more generic
|
400
|
+
store_dir { "uploads/#{model.class.name.parameterize}/#{model.id}/#{attached_as}" }
|
401
|
+
|
402
|
+
# or with a method
|
403
|
+
store_dir :calculate_dir
|
404
|
+
|
405
|
+
def calculate_dir
|
406
|
+
"uploads/posts/images/#{model.id}/"
|
407
|
+
end
|
408
|
+
end
|
183
409
|
```
|
184
410
|
|
185
|
-
|
186
|
-
|
187
|
-
storages by swapping them depending on your use case.
|
411
|
+
Since attachment processing and storing happens on after save, at the time `store_dir` is called the model
|
412
|
+
has already been saved, so the database `id` is available.
|
188
413
|
|
414
|
+
The user is expected to configure such store_dirs appropriately so that path collisions cannot happen across
|
415
|
+
the whole application. To that end, the use of `model.id` and `attached_as` as part of
|
416
|
+
the store dir is a common approach to ensure there will be no collisions. Other options could involve
|
417
|
+
random token generation.
|
189
418
|
|
190
|
-
### public_url
|
191
419
|
|
192
|
-
|
193
|
-
through the application code.
|
420
|
+
#### Processors
|
194
421
|
|
195
|
-
|
196
|
-
|
422
|
+
Processors are methods (or lambdas) that receive the contents of the file being saved and its filename,
|
423
|
+
and in turn return file contents and filename. You can use them to change both values, for example:
|
197
424
|
|
198
|
-
```
|
199
|
-
|
200
|
-
"
|
425
|
+
```ruby
|
426
|
+
class PostImageUploader < Saviour::BaseUploader
|
427
|
+
store_dir { "uploads/posts/#{model.id}/#{attached_as}" }
|
428
|
+
|
429
|
+
process do |contents, filename|
|
430
|
+
new_filename = "#{Digest::MD5.hexdigest(contents)}-#{filename}"
|
431
|
+
new_contents = Zlib::Deflate.deflate(contents)
|
432
|
+
|
433
|
+
[new_contents, new_filename]
|
434
|
+
end
|
201
435
|
end
|
202
436
|
```
|
203
437
|
|
438
|
+
Here we're compressing the contents with ruby's zlib and adding a checksum to the filename for caching purposes. The returning
|
439
|
+
value must be always an array of two values, a pair of contents/filename.
|
204
440
|
|
205
|
-
|
441
|
+
If you want to reuse processors and make them more generic with variables, you can also define them as methods
|
442
|
+
and share them via a ruby module or via inheritance. In this form, you can pass arbitrary arguments.
|
443
|
+
|
444
|
+
```ruby
|
445
|
+
module ProcessorsHelpers
|
446
|
+
def resize(contents, filename, width:, height:)
|
447
|
+
new_contents = SomeImageManipulationImplementation.new(contents).resize_to(width, height)
|
448
|
+
|
449
|
+
[new_contents, filename]
|
450
|
+
end
|
451
|
+
end
|
206
452
|
|
207
|
-
|
453
|
+
class PostImageUploader < Saviour::BaseUploader
|
454
|
+
include ProcessorsHelpers
|
455
|
+
store_dir { "uploads/posts/#{model.id}/#{attached_as}" }
|
208
456
|
|
457
|
+
process :resize, width: 100, height: 100
|
458
|
+
end
|
209
459
|
```
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
460
|
+
|
461
|
+
You may declare as many processors as you want in an uploader class, they will be executed in the same order as
|
462
|
+
you define them and they will be chained: the output of the first processor will be the input of the second one, etc.
|
463
|
+
|
464
|
+
Inside a processor you also have access to the following variables:
|
465
|
+
|
466
|
+
- `model`: The model owner of the file being saved.
|
467
|
+
- `attached_as`: The name of the attachment being processed.
|
468
|
+
- `store_dir`: The computed value of the store dir this file will have.
|
469
|
+
|
470
|
+
When you use `process` to declare processors as seen before, you're given the raw byte contents that were originally
|
471
|
+
assigned to the attachment. This may be convenient if you have a use case when you generate those contents yourself
|
472
|
+
or want to manipulate them directly with ruby, but that's normally not the case. Usually, you assign files to
|
473
|
+
the attachments and modify them via third party binaries (like imagemagick). In that scenario, in order to reduce
|
474
|
+
memory usage, you can use instead `process_with_file`.
|
475
|
+
|
476
|
+
This is essentially the same but instead of raw byte contents you're given a `Tempfile` instance:
|
477
|
+
|
478
|
+
```ruby
|
479
|
+
class PostImageUploader < Saviour::BaseUploader
|
480
|
+
store_dir { "uploads/posts/#{model.id}/#{attached_as}" }
|
481
|
+
|
482
|
+
process_with_file do |file, filename|
|
483
|
+
`convert -thumbnail 100x100^ #{Shellwords.escape(file.path)}`
|
484
|
+
|
485
|
+
[file, filename]
|
486
|
+
end
|
487
|
+
end
|
214
488
|
```
|
215
489
|
|
216
|
-
|
217
|
-
machine. You need to configure this accordingly to your use case and deployment strategies, for example, for rails
|
218
|
-
and capistrano with default settings you'll need to set it to `Rails.root.join("public/system")`.
|
490
|
+
*Note that when escaping to the shell you need to check for safety in case there's an injection in the filename.*
|
219
491
|
|
220
|
-
|
221
|
-
|
222
|
-
|
492
|
+
You can modify directly the contents of the given file in the filesystem, or you could also delete the given file and
|
493
|
+
return a new one. If you return a different file instance, you're expected to clean up the one that was given to you.
|
494
|
+
|
495
|
+
You can mix `process` with `process_with_file` but you should try to avoid it, as it will be a performance penalty
|
496
|
+
having to convert between formats.
|
223
497
|
|
224
|
-
|
498
|
+
Also, even if there's just one `process`, the whole contents of the file will be loaded into memory. Avoid that usage
|
499
|
+
if you're conservative about memory usage or take care of restricting the allowed file size you can work with on
|
500
|
+
any file upload you accept across your application.
|
225
501
|
|
226
502
|
|
227
|
-
|
503
|
+
##### halt_process
|
228
504
|
|
229
|
-
|
505
|
+
`halt_process` is a method you can call from inside a processor in order to abort the processing and storing
|
506
|
+
of the current file. You can use this to conditionally store a file or not based on runtime decisions.
|
230
507
|
|
508
|
+
For example, you may be storing media files that can be audio, video or images, and you want to generate a
|
509
|
+
thumbnail for videos and images but not for audio files.
|
510
|
+
|
511
|
+
```ruby
|
512
|
+
class ThumbImageUploader < Saviour::BaseUploader
|
513
|
+
store_dir { "uploads/thumbs/#{model.id}/#{attached_as}" }
|
514
|
+
|
515
|
+
process_with_file do |file, filename|
|
516
|
+
halt_process unless can_generate_thumb?(file)
|
517
|
+
`convert -thumbnail 100x100^ #{Shellwords.escape(file.path)}`
|
518
|
+
|
519
|
+
[file, filename]
|
520
|
+
end
|
521
|
+
|
522
|
+
def can_generate_thumb?(file)
|
523
|
+
# Some mime type checking
|
524
|
+
end
|
525
|
+
end
|
231
526
|
```
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
527
|
+
|
528
|
+
|
529
|
+
### Versions
|
530
|
+
|
531
|
+
Versions is a common and popular feature on other file management libraries, however, they're usually implemented
|
532
|
+
in a way that makes the "versioned" attachments behave differently than normal attachments.
|
533
|
+
|
534
|
+
Saviour takes another approach: there's no such concept as a "versioned attachment", there're only attachments.
|
535
|
+
The way this works with Saviour is by making one attachment "follow" another one, so that whatever is assigned on
|
536
|
+
the main attachment is also assigned automatically to the follower, and when the main attachment is deleted
|
537
|
+
also is the follower.
|
538
|
+
|
539
|
+
For example:
|
540
|
+
|
541
|
+
```ruby
|
542
|
+
class Post < ApplicationRecord
|
543
|
+
include Saviour::Model
|
544
|
+
|
545
|
+
attach_file :image do
|
546
|
+
store_dir { "uploads/posts/images/#{model.id}/" }
|
547
|
+
end
|
548
|
+
|
549
|
+
attach_file :image_thumb, follow: :image, dependent: :destroy do
|
550
|
+
store_dir { "uploads/posts/image_thumbs/#{model.id}/" }
|
551
|
+
process_with_file :resize, width: 100, height: 100
|
552
|
+
end
|
553
|
+
end
|
237
554
|
```
|
238
555
|
|
239
|
-
|
240
|
-
|
556
|
+
Using the `follow: :image` syntax you declare that the `image_thumb` attachment has to be automatically assigned
|
557
|
+
to the same contents as `image` every time `image` is assigned.
|
241
558
|
|
242
|
-
The `
|
243
|
-
|
244
|
-
the `host` option, as well as `region`, etc.
|
559
|
+
The `:dependent` part is mandatory and indicates if the `image_thumb` attachment has to be removed when the
|
560
|
+
`image` is removed (with `dependent: :destroy`) or not (with `dependent: :ignore`).
|
245
561
|
|
246
|
-
|
562
|
+
```ruby
|
563
|
+
a = Post.create! image: File.open("/path/image.png")
|
564
|
+
a.image # => original file assigned
|
565
|
+
a.image_thumb # => a thumb over the image assigned
|
566
|
+
```
|
247
567
|
|
248
|
-
|
249
|
-
declare options to be used when creating files to S3, and those options will take precedence. Use this for example to
|
250
|
-
set an expiration time for the asset. Example:
|
568
|
+
Now, both attachments are independent:
|
251
569
|
|
570
|
+
```ruby
|
571
|
+
# `image_thumb` can be changed independently
|
572
|
+
a.update_attributes! image_thumb: File.open("/path/another_file.png")
|
573
|
+
|
574
|
+
# or removed
|
575
|
+
a.remove_file_thumb!
|
252
576
|
```
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
577
|
+
|
578
|
+
If `dependent: :destroy` has been choosed, then removing `image` will remove `image_thumb` as well:
|
579
|
+
|
580
|
+
```ruby
|
581
|
+
a.remove_image!
|
582
|
+
a.image? # => false
|
583
|
+
a.image_thumb? # => false
|
584
|
+
````
|
585
|
+
|
586
|
+
If the "versioned attachment" is assigned at the same time as the main one, the provided files will be preserved:
|
587
|
+
|
588
|
+
```ruby
|
589
|
+
a = Post.create! image: File.open("/path/image.png"), image_thumb: File.open("/path/thumb.jpg")
|
590
|
+
a.image # => 'image.png' file
|
591
|
+
a.image_thumb # => 'thumb.jpg' file
|
592
|
+
|
593
|
+
# The same happens when assignations and db saving are separated:
|
594
|
+
|
595
|
+
a = Post.find(42)
|
596
|
+
|
597
|
+
# other code ...
|
598
|
+
a.image_thumb = File.open("/path/thumb.jpg")
|
599
|
+
|
600
|
+
# other code ...
|
601
|
+
a.image = File.open("/path/image.png")
|
602
|
+
|
603
|
+
# other code ...
|
604
|
+
a.save!
|
605
|
+
a.image # => 'image.png' file
|
606
|
+
a.image_thumb # => 'thumb.jpg' file
|
259
607
|
```
|
260
608
|
|
261
|
-
|
262
|
-
|
609
|
+
Finally, even if you selected to use `dependent: :destroy` you may choose to not remove the "versions" when
|
610
|
+
removing the main attachment using an extra argument when removing:
|
263
611
|
|
264
612
|
```ruby
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
raise "The store_dir used is already bigger than 1004 bytes, must be reduced!"
|
271
|
-
end
|
613
|
+
a = Post.create! image: File.open("/path/image.png")
|
614
|
+
a.remove_image!(dependent: :ignore)
|
615
|
+
a.image? # => false
|
616
|
+
a.image_thumb? # => true
|
617
|
+
```
|
272
618
|
|
273
|
-
|
274
|
-
|
275
|
-
# note mb_chars is an active support's method
|
276
|
-
filename.mb_chars.limit(1024 - store_dir.bytesize).to_s
|
277
|
-
else
|
278
|
-
filename
|
279
|
-
end
|
619
|
+
The same is true for the opposite, you could use `remove_image!(dependent: :destroy)` if the attachment was
|
620
|
+
configured as `dependent: :ignore`.
|
280
621
|
|
281
|
-
[contents, new_filename]
|
282
|
-
end
|
283
|
-
```
|
284
622
|
|
623
|
+
### Transactional behavior
|
285
624
|
|
286
|
-
|
625
|
+
When working with attachments inside a database transaction (using Active Record), all the changes made will be
|
626
|
+
reverted if the transaction is rolled back.
|
287
627
|
|
288
|
-
|
289
|
-
|
290
|
-
(you can always set the filename using a processor later on).
|
628
|
+
On file creation (either creating a new AR model or assigning a file for the first time), the file will be
|
629
|
+
available on after save, but will be removed on after rollback.
|
291
630
|
|
292
|
-
|
293
|
-
a random one, or, if the object responds to `#path` then `File.basename(path)` will be used as a name.
|
631
|
+
On file update, changes will be available on after save, but the original file will be restored on after rollback.
|
294
632
|
|
295
|
-
|
296
|
-
|
633
|
+
On file deletion, the file will be no longer available (via Saviour public api) on after save, but the actual deletion
|
634
|
+
will happen on after commit (so in case of rollback the file is never removed).
|
297
635
|
|
298
636
|
|
299
|
-
###
|
637
|
+
### Concurrency
|
300
638
|
|
301
|
-
|
639
|
+
Saviour will run all processors and storage operations concurrently for all attachments present in a model. For example:
|
640
|
+
|
641
|
+
```ruby
|
642
|
+
class Product < ApplicationRecord
|
643
|
+
include Saviour::Model
|
644
|
+
|
645
|
+
attach_file :image, SomeUploader
|
646
|
+
attach_file :image_thumb, SomeUploader, follow: :image
|
647
|
+
attach_file :cover, SomeUploader
|
648
|
+
end
|
302
649
|
|
650
|
+
a = Product.new image: File.open('...'), cover: File.open('...')
|
651
|
+
a.save!
|
303
652
|
```
|
304
|
-
|
305
|
-
|
306
|
-
|
653
|
+
|
654
|
+
At the time that `save!` is executed, 3 threads will be opened. In each one, the processors you defined will be
|
655
|
+
executed for that file, and then the result will be written to the storage.
|
656
|
+
|
657
|
+
In case you have so many attachments that processing them concurrently would be undesired you can limit the
|
658
|
+
max concurrency with:
|
659
|
+
|
660
|
+
```ruby
|
661
|
+
Saviour::Config.concurrent_workers = 2
|
307
662
|
```
|
308
663
|
|
309
|
-
|
664
|
+
The default value is 4.
|
665
|
+
|
666
|
+
|
667
|
+
#### stash
|
668
|
+
|
669
|
+
Note that this means **your processor's code must be thread-safe**. Do not issue db queries from processors
|
670
|
+
directly, for example. They would be executed in a new connection by AR and you may not be expecting that.
|
671
|
+
|
672
|
+
Saviour comes with a simple mechanism to gather data from processors so that you can use it later from
|
673
|
+
the main thread: `stash`. For example:
|
674
|
+
|
675
|
+
|
676
|
+
```ruby
|
677
|
+
class ImageUploader < Saviour::BaseUploader
|
678
|
+
store_dir { "uploads/thumbs/#{model.id}/#{attached_as}" }
|
679
|
+
|
680
|
+
process_with_file do |file, filename|
|
681
|
+
width, height = `identify -format "%wx%h" #{Shellwords.escape(file.path)}`.strip.split(/x/).map(&:to_i)
|
682
|
+
|
683
|
+
stash(
|
684
|
+
width: width,
|
685
|
+
height: height,
|
686
|
+
size: File.size(file.path)
|
687
|
+
)
|
310
688
|
|
311
|
-
|
312
|
-
|
313
|
-
3 times before raising an exception. Example:
|
689
|
+
[file, filename]
|
690
|
+
end
|
314
691
|
|
692
|
+
after_upload do |stash|
|
693
|
+
model.update_attributes!(size: stash[:size], width: stash[:width], height: stash[:height])
|
694
|
+
end
|
695
|
+
end
|
315
696
|
```
|
316
|
-
|
317
|
-
|
318
|
-
|
697
|
+
|
698
|
+
Use `stash(hash)` to push a hash of data from a processor. You can call this multiple times from different processors,
|
699
|
+
the hashes you stash will be deep merged. You can then declare an `after_upload` block that will run in the main
|
700
|
+
thread once all attachments have been saved to the storage. The block will simply receive the stash hash, and from
|
701
|
+
there you can run arbitrary code to persist the info.
|
702
|
+
|
703
|
+
|
704
|
+
### Dirty tracking
|
705
|
+
|
706
|
+
Saviour implements dirty tracking for the attachments. Given the following example:
|
707
|
+
|
708
|
+
```ruby
|
709
|
+
class User < ApplicationRecord
|
710
|
+
include Saviour::Model
|
711
|
+
|
712
|
+
attach_file(:avatar) do
|
713
|
+
store_dir { "uploads/avatars/#{model.id}/" }
|
714
|
+
end
|
715
|
+
end
|
319
716
|
```
|
320
717
|
|
718
|
+
You can now use:
|
719
|
+
|
720
|
+
```ruby
|
721
|
+
a = User.create! avatar: File.open("avatar.jpg")
|
722
|
+
|
723
|
+
a.avatar = File.open("avatar_2.jpg")
|
724
|
+
|
725
|
+
a.changed? # => true
|
726
|
+
|
727
|
+
a.avatar_changed? # => true
|
321
728
|
|
322
|
-
|
729
|
+
a.avatar_was.url # => url pointing to the original avatar.jpg file
|
730
|
+
a.avatar_was.read # => previous byte contents
|
323
731
|
|
324
|
-
|
325
|
-
|
326
|
-
|
732
|
+
a.changed_attributes # => { avatar: <Saviour::File instance of avatar.jpg>}
|
733
|
+
a.avatar_change # => [<Saviour::File instance of avatar.jpg>, <Saviour::File instance of avatar_2.jpg>]
|
734
|
+
a.changes # => { avatar: [<Saviour::File instance of avatar.jpg>, <Saviour::File instance of avatar_2.jpg>] }
|
327
735
|
|
736
|
+
a.save!
|
737
|
+
|
738
|
+
a.avatar_changed? # => false
|
328
739
|
```
|
329
|
-
class ExampleUploader < Saviour::BaseUploader
|
330
|
-
store_dir { "/default/path/#{model.id}" }
|
331
740
|
|
332
|
-
process :resize, width: 50, height: 50
|
333
741
|
|
334
|
-
|
335
|
-
|
336
|
-
|
742
|
+
|
743
|
+
### AR Validations
|
744
|
+
|
745
|
+
You can use `attach_validation` in an Active Record model to declare validations over attachments, for example:
|
746
|
+
|
747
|
+
```ruby
|
748
|
+
class User < ApplicationRecord
|
749
|
+
include Saviour::Model
|
750
|
+
|
751
|
+
attach_file(:avatar) do
|
752
|
+
store_dir { "uploads/avatars/#{model.id}/" }
|
337
753
|
end
|
338
754
|
|
339
|
-
|
340
|
-
|
755
|
+
attach_validation :avatar do |contents, filename|
|
756
|
+
errors.add(:avatar, "max 10 Mb") if contents.bytesize > 10.megabytes
|
757
|
+
errors.add(:avatar, "invalid format") unless %w(jpg jpeg).include?(File.extname(filename))
|
341
758
|
end
|
759
|
+
end
|
760
|
+
```
|
761
|
+
|
762
|
+
Similar as with processors, your block will receive the raw byte contents of the assigned file (or object) and the
|
763
|
+
filename. Adding errors is up to the logic you want to have.
|
342
764
|
|
343
|
-
|
344
|
-
|
345
|
-
|
765
|
+
Validations can also be expressed as methods in the model:
|
766
|
+
|
767
|
+
```ruby
|
768
|
+
class User < ApplicationRecord
|
769
|
+
include Saviour::Model
|
770
|
+
|
771
|
+
attach_file(:avatar) do
|
772
|
+
store_dir { "uploads/avatars/#{model.id}/" }
|
346
773
|
end
|
347
774
|
|
348
|
-
|
775
|
+
attach_validation :avatar, :check_format
|
349
776
|
|
350
|
-
def
|
351
|
-
|
352
|
-
[contents, filename]
|
777
|
+
def check_format(contents, filename)
|
778
|
+
errors.add(:avatar, "invalid format") unless %w(jpg jpeg).include?(File.extname(filename))
|
353
779
|
end
|
354
780
|
end
|
355
781
|
```
|
356
782
|
|
357
|
-
|
783
|
+
In both forms (block or method) an additional 3rd argument will be provided as a hash of `{attached_as: "avatar"}`
|
784
|
+
in this example. You can use this to apply different logic per attachment in case of shared validations.
|
785
|
+
|
786
|
+
Those validations will run on before save, so none of the processors you may have defined did run yet. The contents
|
787
|
+
and filename provided in the validation are the ones originally assigned to the attachment.
|
358
788
|
|
359
|
-
|
360
|
-
`
|
789
|
+
You can also use the variation `attach_validation_with_file`, which is the same but instead of raw contents you're
|
790
|
+
given a `File` object to work with. Use this to preserve memory if that's your use case, same considerations apply
|
791
|
+
as in the processor's case.
|
361
792
|
|
362
|
-
Note that it's very important that the full path to any attached file to any model is unique. This is typically
|
363
|
-
accomplished by using `model.id` and `attached_as` as part of either the `store_dir` or the `filename`, in any
|
364
|
-
combination you may want. If this is not satisfied, you may experience unexpected overwrite of files or files
|
365
|
-
having unexpected contents, for example if two different models write to the same storage path, and then one
|
366
|
-
of them is deleted.
|
367
793
|
|
794
|
+
### Introspection
|
368
795
|
|
369
|
-
|
796
|
+
Two methods are added to any class including `Saviour::Model` to give you information about what attachments
|
797
|
+
have been defined in that class.
|
370
798
|
|
371
|
-
|
372
|
-
representing a method. In both cases, you can directly access there a method called `model` and a method called
|
373
|
-
`attached_as`, representing the original model and the name under which the file is attached to the model.
|
799
|
+
`Model.attached_files` will give an array of symbols, representing all the attachments declared in that class.
|
374
800
|
|
375
|
-
|
376
|
-
|
801
|
+
`Model.attached_followers_per_leader` will give a hash where the keys are attachments that have versions
|
802
|
+
assigned, and the values being an array of symbols, representing the attachments that are following that attachment.
|
377
803
|
|
378
|
-
|
804
|
+
```ruby
|
805
|
+
class Post < ApplicationRecord
|
806
|
+
include Saviour::Model
|
379
807
|
|
380
|
-
|
381
|
-
|
808
|
+
attach_file :image, SomeUploader
|
809
|
+
attach_file :image_thumb, SomeUploader, follow: :image, dependent: :destroy
|
810
|
+
attach_file :image_thumb_2, SomeUploader, follow: :image, dependent: :destroy
|
811
|
+
attach_file :cover, SomeUploader
|
812
|
+
end
|
813
|
+
|
814
|
+
Post.attached_files # => [:image, :image_thumb, :image_thumb_2, :cover]
|
815
|
+
Post.attached_followers_per_leader # => { image: [:image_thumb, :image_thumb_2] }
|
816
|
+
```
|
382
817
|
|
383
|
-
They work as a stack, chaining the response from the previous one as input for the next one, and are executed in the
|
384
|
-
same order you declare them. Each processor will receive the raw contents and the filename, and must return an array
|
385
|
-
with two values, the new contents and the new filename.
|
386
818
|
|
387
|
-
|
819
|
+
## Extras & Advance usage
|
388
820
|
|
389
|
-
|
390
|
-
You can optionally set an extra Hash of options that will be forwarded to the method, so it becomes easier to reuse processors.
|
821
|
+
### Skip processors
|
391
822
|
|
392
|
-
|
823
|
+
Saviour has a configuration flag called `processing_enabled` that controls whether or not to execute processors.
|
824
|
+
You can set it:
|
393
825
|
|
394
|
-
|
395
|
-
using the `process` method. However, since there are use cases for which is more convenient to have a File object
|
396
|
-
instead of the raw contents, you can also use the `process_with_file` method, which will give you a Tempfile object,
|
397
|
-
and from which you must return a File object as well.
|
826
|
+
`Saviour::Config.processing_enabled = false`
|
398
827
|
|
399
|
-
|
400
|
-
|
401
|
-
Internally Saviour works with raw contents, so even if you only use `process_with_file`, there will be a penalty at the
|
402
|
-
beginning and at the end, for writing and reading to and from a file.
|
828
|
+
It's thread-safe and can be changed on the fly. Use it if you, for some reason, need to skip processing in a general
|
829
|
+
way.
|
403
830
|
|
404
|
-
|
405
|
-
`process_with_file` will be automatically deleted by Saviour. Be aware of this if you return
|
406
|
-
some File instance different than the one you received pointing to a file.
|
831
|
+
### Testing
|
407
832
|
|
408
|
-
|
833
|
+
As file management is an expensive operation if you're working with a remote storage like s3, there
|
834
|
+
are some things that you might want to change during test execution.
|
409
835
|
|
410
|
-
|
411
|
-
|
412
|
-
|
836
|
+
First of all, you can use a local storage on tests instead of s3, only this will speed up your suite a lot.
|
837
|
+
If you have some tests that must run against s3, you can use an s3 spec flag to conditionally
|
838
|
+
swap storages on the fly:
|
413
839
|
|
414
840
|
```ruby
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
841
|
+
# config/env/test.rb
|
842
|
+
Saviour::Config.storage = ::LocalStorage.new(...)
|
843
|
+
|
844
|
+
# spec/support/saviour.rb
|
845
|
+
module S3Stub
|
846
|
+
mattr_accessor :storage
|
847
|
+
|
848
|
+
self.storage = Saviour::S3Storage.new(...)
|
849
|
+
end
|
850
|
+
|
851
|
+
RSpec.configure do |config|
|
852
|
+
config.around(:example, s3_storage: true) do |example|
|
853
|
+
previous_storage = Saviour::Config.storage
|
854
|
+
Saviour::Config.storage = S3Stub.storage
|
855
|
+
|
856
|
+
example.call
|
857
|
+
|
858
|
+
Saviour::Config.storage = previous_storage
|
420
859
|
end
|
421
860
|
end
|
422
|
-
```
|
423
861
|
|
424
|
-
|
862
|
+
it "some regular test" do
|
863
|
+
# local storage here
|
864
|
+
end
|
425
865
|
|
866
|
+
it "some test with s3", s3_storage: true do
|
867
|
+
# s3 storage here
|
868
|
+
end
|
426
869
|
```
|
870
|
+
|
871
|
+
Finally, you can also choose to disable execution of all processors during tests:
|
872
|
+
|
873
|
+
```ruby
|
874
|
+
# spec/support/saviour.rb
|
875
|
+
|
427
876
|
Saviour::Config.processing_enabled = false
|
428
|
-
Saviour::Config.processing_enabled = true
|
429
877
|
```
|
430
878
|
|
431
|
-
|
432
|
-
|
879
|
+
This will skip all processors, so you'll avoid image manipulations, etc. If you have a more complex application
|
880
|
+
and you can't disable all processors, but still would want to skip only the ones related to image manipulation,
|
881
|
+
I would recommend to delegate image manipulation to a specialized class and then stub all of their methods.
|
882
|
+
|
433
883
|
|
884
|
+
### Sources: url and string
|
434
885
|
|
435
|
-
|
886
|
+
Saviour comes with two small utility classes to encapsulate values to assign as attachments.
|
436
887
|
|
437
|
-
|
438
|
-
persist the file path, and this means you can work with them completely independently of the main file. They can be
|
439
|
-
assigned, deleted, etc... independently. You just need to work with the versioned `Saviour::File` instance instead of the main
|
440
|
-
one, so for example when assigning a file you'll need to do `object.file(:thumb).assign(my_file)`.
|
888
|
+
If you want to provide directly the contents and filename, you can use `Saviour::StringSource`:
|
441
889
|
|
442
|
-
|
890
|
+
`Post.create! image: Saviour::StringSource.new("hello world", "file.txt")`
|
443
891
|
|
444
|
-
|
892
|
+
If you want to assign a file stored in an http endpoint, you can use `Saviour::UrlSource`:
|
445
893
|
|
446
|
-
|
447
|
-
main file, and all versions will be deleted when deleting the main file.
|
894
|
+
`Post.create! image: Saviour::UrlSource.new("https://dummyimage.com/600x400/000/fff")`
|
448
895
|
|
449
|
-
In case of conflict, the versioned assignation will be preserved. For example, if you assign both the main file and the version,
|
450
|
-
both of them will be respected and the main file will not propagate to the version in this case.
|
451
896
|
|
452
|
-
|
453
|
-
a version directly (see validation section for details).
|
897
|
+
### Custom Storages
|
454
898
|
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
the first processors will be executed 3 times.
|
899
|
+
An storage is a class that implements the public api expected by Saviour. The abstraction expected
|
900
|
+
by Saviour is that, whatever the underlying platform or technology, the storage is able to persist
|
901
|
+
the given file using the given path as a unique identifier.
|
459
902
|
|
903
|
+
The complete public api that must be satisfied is:
|
460
904
|
|
461
|
-
|
905
|
+
- write(raw_contents, path): Given raw byte contents and a full path, the storage is expected to
|
906
|
+
persist those contents indexed by the given path, so that later on can be retrieved by the same path.
|
907
|
+
The return value is ignored.
|
462
908
|
|
463
|
-
|
909
|
+
- read(path): Returns the raw contents stored in the given path.
|
464
910
|
|
465
|
-
|
466
|
-
|
467
|
-
|
911
|
+
- write_from_file(file, path): Same as write, but providing a file object rather than raw contents. The storage
|
912
|
+
has the opportunity to implement this operation in a more performant way, if possible (local storage does here
|
913
|
+
a `cp`, for example). The return value is ignored.
|
468
914
|
|
469
|
-
|
915
|
+
- read_to_file(path, file): Same as read, but writing to the given file directly instead of returning raw values.
|
916
|
+
The storage has the opportunity to implement this operation in a more performant way, if possible.
|
917
|
+
The return value is ignored.
|
470
918
|
|
471
|
-
|
472
|
-
class Post < ActiveRecord::Base
|
473
|
-
include Saviour::Model
|
474
|
-
attach_file :image, PostImageUploader
|
919
|
+
- delete(path): Removes the file stored at the given path. The return value is ignored.
|
475
920
|
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
921
|
+
- exists?(path): Returns a boolean true/false, depending if the given path is present in the storage or not.
|
922
|
+
S3 storage implements this with a HEAD request, for example.
|
923
|
+
|
924
|
+
- public_url(path): Returns a string corresponding to an URL under which the file represented by the given
|
925
|
+
path is available.
|
926
|
+
|
927
|
+
- cp(source_path, destination_path): Copies the file from "source_path" into "destination_path". Overwrites
|
928
|
+
"destination_path" if necessary.
|
929
|
+
|
930
|
+
- mv(source_path, destination_path): Moves the file from "source_path" into "destination_path". Overwrites
|
931
|
+
"destination_path" if necessary, and removes the file at "source_path".
|
932
|
+
|
933
|
+
`cp` and `mv` are explicitly created in order to give a chance to the storage to implement the feature in a more
|
934
|
+
performant way, for example, s3 implements `cp` as direct copy inside s3 without downloading/uploading the file.
|
935
|
+
|
936
|
+
If the given path does not correspond with an existing file, in the case of `read`, `read_to_file`, `delete`, `cp` or
|
937
|
+
`mv`, the storage is expected to raise the `Saviour::FileNotPresent` exception.
|
938
|
+
|
939
|
+
Any additional information the storage may require can be provided on instance creation (on `initialize`) since
|
940
|
+
this is not used by Saviour.
|
941
|
+
|
942
|
+
|
943
|
+
### Bypassing Saviour
|
944
|
+
|
945
|
+
The only reference to stored files Saviour holds and uses is the path persisted in the database. If you want to,
|
946
|
+
you can directly manipulate the storage contents and the database in any custom way and Saviour will just pick
|
947
|
+
the changes and work from there.
|
948
|
+
|
949
|
+
Since Saviour is by design model-based, there may be use cases when this becomes a performance issue, for example:
|
950
|
+
|
951
|
+
##### Bypass example: Nested Cloning
|
952
|
+
|
953
|
+
Say that you have a model `Post` that has many `Image`s, and you're working with S3. `Post` has 3 attachments and
|
954
|
+
`Image` has 2 attachments. If you want to do a feature to "clone" a post, a simple implementation would be to
|
955
|
+
basically `dup` the instances and save them.
|
956
|
+
|
957
|
+
However, for a post with many related images, this would represent many api calls and roundtrips to download
|
958
|
+
contents and re-upload them. It would be a lot faster to work with s3 directly, issue api calls to copy the
|
959
|
+
files inside s3 directly (no download/upload, and even you could issue those api calls concurrently),
|
960
|
+
and then assign manually crafted paths directly to the new instances.
|
961
|
+
|
962
|
+
|
963
|
+
## FAQ
|
964
|
+
|
965
|
+
### how to reuse code in your app, attachment with defaults
|
966
|
+
|
967
|
+
If your application manages many file attachments and you want certain things to apply to all of them, you can
|
968
|
+
extract common behaviors into a module:
|
969
|
+
|
970
|
+
```ruby
|
971
|
+
module FileAttachmentHelpers
|
972
|
+
# Shared processors
|
480
973
|
end
|
481
|
-
```
|
482
974
|
|
483
|
-
|
484
|
-
|
975
|
+
module FileAttachment
|
976
|
+
extend ActiveSupport::Concern
|
977
|
+
|
978
|
+
included do
|
979
|
+
include Saviour::Model
|
980
|
+
end
|
485
981
|
|
486
|
-
|
982
|
+
class_methods do
|
983
|
+
def attach_file_with_defaults(*args, &block)
|
984
|
+
attached_as = args[0]
|
487
985
|
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
attach_validation :image, :check_size
|
986
|
+
attach_file(*args) do
|
987
|
+
include FileAttachmentHelpers
|
988
|
+
|
989
|
+
store_dir { "uploads/#{model.class.name.parameterize}/#{model.id}/#{attached_as}" }
|
493
990
|
|
494
|
-
|
991
|
+
instance_eval(&block) if block
|
992
|
+
process_with_file :sanitize_filename
|
993
|
+
process_with_file :digest_filename
|
994
|
+
process_with_file :truncate_at_max_key_size
|
995
|
+
end
|
495
996
|
|
496
|
-
|
497
|
-
|
997
|
+
attach_validation_with_file(attached_as) do |file, _|
|
998
|
+
errors.add(attached_as, 'is an empty file') if ::File.size(file.path).zero?
|
999
|
+
end
|
1000
|
+
end
|
1001
|
+
|
1002
|
+
def validate_extension(*validated_attachments, as:)
|
1003
|
+
formats = Array.wrap(as).map(&:to_s)
|
1004
|
+
|
1005
|
+
validated_attachments.each do |attached_as|
|
1006
|
+
attach_validation_with_file(attached_as) do |_, filename|
|
1007
|
+
ext = ::File.extname(filename)
|
1008
|
+
unless formats.include?(ext.downcase.delete('.'))
|
1009
|
+
errors.add(attached_as, "must have any of the following extensions: '#{formats}'")
|
1010
|
+
end
|
1011
|
+
end
|
1012
|
+
end
|
1013
|
+
end
|
1014
|
+
end
|
1015
|
+
end
|
1016
|
+
|
1017
|
+
class Post < ApplicationRecord
|
1018
|
+
include FileAttachment
|
1019
|
+
|
1020
|
+
attach_file_with_defaults :cover # Nothing extra needed
|
1021
|
+
|
1022
|
+
attach_file_with_defaults :image do
|
1023
|
+
process_with_file :some_extra_thing
|
498
1024
|
end
|
499
1025
|
end
|
500
1026
|
```
|
501
1027
|
|
502
|
-
|
503
|
-
implementation). This third argument is a hash containing `attached_as` and `version` of the validating file.
|
1028
|
+
In this example we're encapsulating many behaviors that will be given for free to any declared attachments:
|
504
1029
|
|
1030
|
+
- `store_dir` computed by default into a path that will be different for each class / id / attached_as.
|
1031
|
+
- 3 generic processors are always run, `sanitize_filename` to ensure we'll have a sane url in the end, `digest_filename` to append a digest and `truncate_at_max_key_size` to ensure we don't reach the 1024 bytes imposed by S3.
|
1032
|
+
- All attachments will validate that the assigned file must not be empty (0 bytes file).
|
1033
|
+
- An utility method is added to allow for validations against the filename extension with `validate_extension :image, as: %w[jpg jpeg png]`
|
505
1034
|
|
506
|
-
## Active Record Lifecycle integration
|
507
1035
|
|
508
|
-
|
1036
|
+
### How to manage file removal from forms
|
509
1037
|
|
510
|
-
|
1038
|
+
This feature can be implemented with a temporal flag in the model, which is exposed in the forms and passed via
|
1039
|
+
controllers, and a `before_update` to read the value and delete the attachment if present. For example, the
|
1040
|
+
`FileAttachment` module exposed in the previous point could be extended as such:
|
511
1041
|
|
512
|
-
|
1042
|
+
```ruby
|
1043
|
+
module FileAttachment
|
1044
|
+
# ...
|
1045
|
+
class_methods do
|
1046
|
+
def attach_file_with_defaults(*args, &block)
|
1047
|
+
attached_as = args[0]
|
1048
|
+
# ...
|
1049
|
+
|
1050
|
+
define_method("remove_#{attached_as}") do
|
1051
|
+
instance_variable_get("@remove_#{attached_as}")
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
alias_method "remove_#{attached_as}?", "remove_#{attached_as}"
|
1055
|
+
|
1056
|
+
define_method("remove_#{attached_as}=") do |value|
|
1057
|
+
instance_variable_set "@remove_#{attached_as}", ActiveRecord::Type::Boolean.new.cast(value)
|
1058
|
+
end
|
1059
|
+
|
1060
|
+
before_update do
|
1061
|
+
send("remove_#{attached_as}!") if send("remove_#{attached_as}?")
|
1062
|
+
end
|
1063
|
+
end
|
1064
|
+
end
|
1065
|
+
end
|
1066
|
+
```
|
513
1067
|
|
514
|
-
|
515
|
-
to the validation blocks and cached. If the model is valid, the upload will happen from those cached contents. If there
|
516
|
-
are no validations, the source will be readed only on upload time, after validating the model.
|
1068
|
+
Then it can be used as:
|
517
1069
|
|
1070
|
+
```ruby
|
1071
|
+
# This would be a controller code
|
1072
|
+
a = Post.find(42)
|
518
1073
|
|
519
|
-
|
1074
|
+
# Params received from a form
|
1075
|
+
a.update_attributes(remove_image: "t")
|
1076
|
+
```
|
520
1077
|
|
521
|
-
This is a compilation of common questions or features regarding file uploads.
|
522
1078
|
|
523
|
-
###
|
1079
|
+
### How to extract metadata from files
|
524
1080
|
|
525
|
-
|
526
|
-
|
1081
|
+
You can use processors to accomplish this. Just be aware that processors run concurrently, so if you want to
|
1082
|
+
persist you extracted information in the database probably you'll want to use `stash`, see [the section
|
1083
|
+
about stash feature for examples](#stash).
|
527
1084
|
|
528
|
-
```
|
529
|
-
def digest_filename(contents, filename, opts = {})
|
530
|
-
separator = opts.fetch(:separator, "-")
|
531
1085
|
|
532
|
-
|
533
|
-
extension = ::File.extname(filename)
|
1086
|
+
### How to process files in background / delayed
|
534
1087
|
|
535
|
-
|
1088
|
+
As a previous warning note, pushing logic to be run in the background, when they have visible consequences for the application, may
|
1089
|
+
have undesired side effects and added complexity. For example, as you can't be sure about when the delayed job
|
1090
|
+
will be completed, your application now needs to handle the uncertainty about the situation: The file processing may
|
1091
|
+
or may not have run yet.
|
536
1092
|
|
537
|
-
|
1093
|
+
Implementing a delayed processor means that Saviour is no longer involved in the process. You could add the
|
1094
|
+
enqueuing of the job when you detect a change in the attachment:
|
1095
|
+
|
1096
|
+
```ruby
|
1097
|
+
class Post < ApplicationRecord
|
1098
|
+
include Saviour::Model
|
1099
|
+
attach_file :image
|
1100
|
+
|
1101
|
+
before_save do
|
1102
|
+
if image_changed?
|
1103
|
+
# On after commit, enqueue the job
|
1104
|
+
end
|
538
1105
|
end
|
1106
|
+
end
|
539
1107
|
```
|
540
1108
|
|
1109
|
+
The job then should take the model and the attachment to process and run the processings directly:
|
1110
|
+
|
1111
|
+
```ruby
|
1112
|
+
a = Post.find(42)
|
1113
|
+
a.image.with_copy do |f|
|
1114
|
+
# manipulate f as desired
|
1115
|
+
a.update_attributes! image: f
|
1116
|
+
end
|
1117
|
+
```
|
1118
|
+
|
1119
|
+
|
541
1120
|
### How to recreate versions
|
542
1121
|
|
543
|
-
|
544
|
-
|
545
|
-
if that's something you want.
|
1122
|
+
As "versions" are just regular attachments, you only need to assign to it the contents of the main attachment. You can
|
1123
|
+
also directly assign attachments between themselves. For example:
|
546
1124
|
|
547
|
-
|
1125
|
+
```ruby
|
1126
|
+
class Post < ApplicationRecord
|
1127
|
+
include Saviour::Model
|
1128
|
+
|
1129
|
+
attach_file :image, SomeUploader
|
1130
|
+
attach_file :image_thumb, SomeUploader, follow: :image, dependent: :destroy
|
1131
|
+
end
|
548
1132
|
|
1133
|
+
post = Post.find 42
|
1134
|
+
post.image_thumb = post.image
|
1135
|
+
post.save!
|
549
1136
|
```
|
550
|
-
class SaviourRecreateVersionsService
|
551
|
-
def initialize(model)
|
552
|
-
@model = model
|
553
|
-
end
|
554
1137
|
|
555
|
-
|
556
|
-
|
1138
|
+
### How to digest the filename
|
1139
|
+
|
1140
|
+
You can use a processor like this one:
|
1141
|
+
|
1142
|
+
```ruby
|
1143
|
+
def digest_filename(file, filename, opts = {})
|
1144
|
+
separator = opts.fetch(:separator, '-')
|
1145
|
+
|
1146
|
+
digest = ::Digest::MD5.file(file.path).hexdigest
|
1147
|
+
extension = ::File.extname(filename)
|
1148
|
+
|
1149
|
+
previous_filename = ::File.basename(filename, '.*')
|
557
1150
|
|
558
|
-
|
559
|
-
|
560
|
-
|
1151
|
+
if Regexp.new("[0-9a-f]{32}#{Regexp.escape(extension)}$").match(filename)
|
1152
|
+
# Remove the previous digest if found
|
1153
|
+
previous_filename = previous_filename.split(separator)[0...-1].join(separator)
|
561
1154
|
end
|
562
1155
|
|
563
|
-
|
1156
|
+
new_filename = "#{previous_filename}#{separator}#{digest}#{extension}"
|
1157
|
+
|
1158
|
+
[file, new_filename]
|
564
1159
|
end
|
565
|
-
end
|
566
1160
|
```
|
567
1161
|
|
568
|
-
|
1162
|
+
## License
|
1163
|
+
|
1164
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
569
1165
|
|
570
|
-
### Caching across redisplays in normal forms
|
571
|
-
### Introspection (Class.attached_files)
|
572
|
-
### Processing in background
|