shrine 2.10.1 → 2.11.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 +5 -5
- data/CHANGELOG.md +25 -1
- data/README.md +241 -393
- data/doc/advantages.md +346 -0
- data/doc/attacher.md +1 -1
- data/doc/carrierwave.md +9 -9
- data/doc/creating_storages.md +172 -84
- data/doc/design.md +1 -1
- data/doc/direct_s3.md +98 -85
- data/doc/metadata.md +213 -0
- data/doc/migrating_storage.md +1 -1
- data/doc/multiple_files.md +4 -3
- data/doc/paperclip.md +4 -4
- data/doc/processing.md +415 -0
- data/doc/refile.md +23 -23
- data/doc/testing.md +47 -51
- data/doc/validation.md +148 -0
- data/lib/shrine.rb +45 -4
- data/lib/shrine/plugins/add_metadata.rb +35 -14
- data/lib/shrine/plugins/determine_mime_type.rb +7 -5
- data/lib/shrine/plugins/direct_upload.rb +3 -1
- data/lib/shrine/plugins/infer_extension.rb +1 -1
- data/lib/shrine/plugins/metadata_attributes.rb +2 -2
- data/lib/shrine/plugins/presign_endpoint.rb +27 -17
- data/lib/shrine/plugins/rack_response.rb +4 -4
- data/lib/shrine/plugins/signature.rb +1 -1
- data/lib/shrine/plugins/store_dimensions.rb +10 -18
- data/lib/shrine/plugins/upload_endpoint.rb +22 -0
- data/lib/shrine/plugins/versions.rb +10 -14
- data/lib/shrine/storage/linter.rb +11 -0
- data/lib/shrine/storage/s3.rb +57 -30
- data/lib/shrine/version.rb +2 -2
- data/shrine.gemspec +3 -3
- metadata +11 -7
data/doc/migrating_storage.md
CHANGED
@@ -13,7 +13,7 @@ current store (let's say that you're migrating from FileSystem to S3):
|
|
13
13
|
```rb
|
14
14
|
Shrine.storages = {
|
15
15
|
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
|
16
|
-
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads
|
16
|
+
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"),
|
17
17
|
new_store: Shrine::Storage::S3.new(**s3_options),
|
18
18
|
}
|
19
19
|
|
data/doc/multiple_files.md
CHANGED
@@ -83,8 +83,9 @@ end
|
|
83
83
|
In order to allow the user to select multiple files in the form, we just need
|
84
84
|
to add the `multiple` attribute to the file field.
|
85
85
|
|
86
|
-
```
|
87
|
-
|
86
|
+
```rb
|
87
|
+
f.input :file, name: :file, multiple: true
|
88
|
+
# <input type="file" name="file" multiple />
|
88
89
|
```
|
89
90
|
|
90
91
|
On the client side you can then asynchronously upload each of the selected
|
@@ -112,4 +113,4 @@ automatically take care of the attachment management.
|
|
112
113
|
|
113
114
|
[`Sequel::Model.nested_attributes`]: http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/NestedAttributes.html
|
114
115
|
[`ActiveRecord::Base.accepts_nested_attributes_for`]: http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
|
115
|
-
[Direct Uploads to S3]:
|
116
|
+
[Direct Uploads to S3]: https://shrinerb.com/rdoc/files/doc/direct_s3_md.html
|
data/doc/paperclip.md
CHANGED
@@ -409,7 +409,7 @@ which you have to register:
|
|
409
409
|
```rb
|
410
410
|
Shrine.storages = {
|
411
411
|
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
|
412
|
-
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads
|
412
|
+
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"),
|
413
413
|
}
|
414
414
|
```
|
415
415
|
|
@@ -578,6 +578,6 @@ The Shrine storage has no replacement for the `:url` Paperclip option, and it
|
|
578
578
|
isn't needed.
|
579
579
|
|
580
580
|
[file]: http://linux.die.net/man/1/file
|
581
|
-
[Reprocessing versions]:
|
582
|
-
[direct S3 uploads]:
|
583
|
-
[`Shrine::Storage::S3`]:
|
581
|
+
[Reprocessing versions]: https://shrinerb.com/rdoc/files/doc/regenerating_versions_md.html
|
582
|
+
[direct S3 uploads]: https://shrinerb.com/rdoc/files/doc/direct_s3_md.html
|
583
|
+
[`Shrine::Storage::S3`]: https://shrinerb.com/rdoc/classes/Shrine/Storage/S3.html
|
data/doc/processing.md
ADDED
@@ -0,0 +1,415 @@
|
|
1
|
+
# File Processing
|
2
|
+
|
3
|
+
Shrine allows you to process files before they're uploaded to a storage. It's
|
4
|
+
generally best to process cached files when they're being promoted to permanent
|
5
|
+
storage, because (a) at that point the file has already been successfully
|
6
|
+
validated, (b) the parent record has been saved and the database transaction
|
7
|
+
has been committed, and (c) this can be delayed into a background job.
|
8
|
+
|
9
|
+
You can define processing using the `processing` plugin, which we'll use to
|
10
|
+
hook into the `:store` phase (when cached file is uploaded to permanent
|
11
|
+
storage).
|
12
|
+
|
13
|
+
```rb
|
14
|
+
class ImageUploader < Shrine
|
15
|
+
plugin :processing
|
16
|
+
|
17
|
+
process(:store) do |io, context|
|
18
|
+
io #=> #<Shrine::UploadedFile ...>
|
19
|
+
context #=> {:record=>#<Photo...>,:name=>:image,...}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
```
|
23
|
+
|
24
|
+
The processing block yields two arguments: `io`, a [`Shrine::UploadedFile`]
|
25
|
+
object that's uploaded to temporary storage, and `context`, a Hash that
|
26
|
+
contains additional data such as the model instance and attachment name. The
|
27
|
+
block result should be file(s) that will be uploaded to permanent storage.
|
28
|
+
|
29
|
+
Shrine treats processing as a functional transformation; you are given the
|
30
|
+
original file, and how you're going to perform processing is entirely up to
|
31
|
+
you, you only need to return the processed files at the end of the block that
|
32
|
+
you want to save. Then Shrine will continue to upload those files to the
|
33
|
+
storage. Note that **it's recommended to always keep the original file**, just
|
34
|
+
in case you'll ever need to reprocess it.
|
35
|
+
|
36
|
+
It's a good idea to also load the `delete_raw` plugin to automatically delete
|
37
|
+
processed files after they're uploaded.
|
38
|
+
|
39
|
+
```rb
|
40
|
+
class ImageUploader < Shrine
|
41
|
+
plugin :processing
|
42
|
+
plugin :delete_raw # automatically delete processed files after uploading
|
43
|
+
|
44
|
+
# ...
|
45
|
+
end
|
46
|
+
```
|
47
|
+
|
48
|
+
## Single file
|
49
|
+
|
50
|
+
Let's say that you have an image that you want to optimize before it's saved
|
51
|
+
to permanent storage. This is how you might do it with the [image_optim] gem:
|
52
|
+
|
53
|
+
```rb
|
54
|
+
# Gemfile
|
55
|
+
gem "image_optim"
|
56
|
+
gem "image_optim_pack" # precompiled binaries
|
57
|
+
```
|
58
|
+
|
59
|
+
```rb
|
60
|
+
require "image_optim"
|
61
|
+
|
62
|
+
class ImageUploader < Shrine
|
63
|
+
plugin :processing
|
64
|
+
plugin :delete_raw
|
65
|
+
|
66
|
+
process(:store) do |io, context|
|
67
|
+
original = io.download
|
68
|
+
|
69
|
+
image_optim = ImageOptim.new
|
70
|
+
optimized_path = image_optim.optimize_image(original.path)
|
71
|
+
|
72
|
+
original.close!
|
73
|
+
|
74
|
+
File.open(optimized_path, "rb")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
```
|
78
|
+
|
79
|
+
Notice that, because the image_optim gem works with files on disk, we had to
|
80
|
+
download the cached file from temporary storage before optimizing it.
|
81
|
+
Afterwards we also close and delete it using `Tempfile#close!`.
|
82
|
+
|
83
|
+
## Versions
|
84
|
+
|
85
|
+
When you're handling images, it's very common to want to generate various
|
86
|
+
thumbnails from the original image, and display them on your site. It's
|
87
|
+
recommended to use the **[ImageProcessing]** gem for generating image
|
88
|
+
thumbnails, as it has a convenient and flexible API, and comes with good
|
89
|
+
defaults for the web.
|
90
|
+
|
91
|
+
Since we'll be storing multiple derivates of the original file, we'll need to
|
92
|
+
also load the `versions` plugin, which allows us to return a Hash of processed
|
93
|
+
files. For processing we'll be using the `ImageProcessing::MiniMagick` backend,
|
94
|
+
which performs processing with [ImageMagick]/[GraphicsMagick].
|
95
|
+
|
96
|
+
```sh
|
97
|
+
$ brew install imagemagick
|
98
|
+
```
|
99
|
+
```rb
|
100
|
+
# Gemfile
|
101
|
+
gem "image_processing", "~> 1.0"
|
102
|
+
```
|
103
|
+
|
104
|
+
```rb
|
105
|
+
require "image_processing/mini_magick"
|
106
|
+
|
107
|
+
class ImageUploader < Shrine
|
108
|
+
plugin :processing
|
109
|
+
plugin :versions
|
110
|
+
plugin :delete_raw
|
111
|
+
|
112
|
+
process(:store) do |io, context|
|
113
|
+
original = io.download
|
114
|
+
pipeline = ImageProcessing::MiniMagick.source(original)
|
115
|
+
|
116
|
+
size_800 = pipeline.resize_to_limit!(800, 800)
|
117
|
+
size_500 = pipeline.resize_to_limit!(500, 500)
|
118
|
+
size_300 = pipeline.resize_to_limit!(300, 300)
|
119
|
+
|
120
|
+
original.close!
|
121
|
+
|
122
|
+
{ original: io, large: size_800, medium: size_500, small: size_300 }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
```
|
126
|
+
|
127
|
+
### libvips
|
128
|
+
|
129
|
+
Alternatively, you can also process files with **[libvips]**, which has shown
|
130
|
+
to be multiple times faster than ImageMagick, with lower memory usage on top of
|
131
|
+
that (see [Why is libvips quick]). Using libvips is as easy as installing libvips
|
132
|
+
and switching to the `ImageProcessing::Vips` backend.
|
133
|
+
|
134
|
+
```sh
|
135
|
+
$ brew install vips
|
136
|
+
```
|
137
|
+
|
138
|
+
```rb
|
139
|
+
require "image_processing/vips"
|
140
|
+
|
141
|
+
class ImageUploader < Shrine
|
142
|
+
plugin :processing
|
143
|
+
plugin :versions
|
144
|
+
plugin :delete_raw
|
145
|
+
|
146
|
+
process(:store) do |io, context|
|
147
|
+
original = io.download
|
148
|
+
pipeline = ImageProcessing::Vips.source(original)
|
149
|
+
|
150
|
+
size_800 = pipeline.resize_to_limit!(800, 800)
|
151
|
+
size_500 = pipeline.resize_to_limit!(500, 500)
|
152
|
+
size_300 = pipeline.resize_to_limit!(300, 300)
|
153
|
+
|
154
|
+
original.close!
|
155
|
+
|
156
|
+
{ original: io, large: size_800, medium: size_500, small: size_300 }
|
157
|
+
end
|
158
|
+
end
|
159
|
+
```
|
160
|
+
|
161
|
+
### External
|
162
|
+
|
163
|
+
Since processing is so dynamic, you're not limited to using the ImageProcessing
|
164
|
+
gem, you can also use a 3rd-party service to generate thumbnails for you. Here
|
165
|
+
is the same example as above, but this time using [ImageOptim.com] to do the
|
166
|
+
processing (not to be confused with the [image_optim] gem):
|
167
|
+
|
168
|
+
```rb
|
169
|
+
# Gemfile
|
170
|
+
gem "down", "~> 4.4"
|
171
|
+
gem "http", "~> 3.2"
|
172
|
+
```
|
173
|
+
|
174
|
+
```rb
|
175
|
+
require "down/http"
|
176
|
+
|
177
|
+
class ImageUploader < Shrine
|
178
|
+
plugin :processing
|
179
|
+
plugin :versions
|
180
|
+
plugin :delete_raw
|
181
|
+
|
182
|
+
IMAGE_OPTIM_URL = "https://im2.io/<USERNAME>"
|
183
|
+
|
184
|
+
process(:store) do |io, context|
|
185
|
+
down = Down::Http.new(method: :post)
|
186
|
+
|
187
|
+
size_800 = down.download("#{IMAGE_OPTIM_URL}/800x800/#{io.url}")
|
188
|
+
size_500 = down.download("#{IMAGE_OPTIM_URL}/500x500/#{io.url}")
|
189
|
+
size_300 = down.download("#{IMAGE_OPTIM_URL}/300x300/#{io.url}")
|
190
|
+
|
191
|
+
{ original: io, large: size_800, medium: size_500, small: size_300 }
|
192
|
+
end
|
193
|
+
end
|
194
|
+
```
|
195
|
+
|
196
|
+
We used the [Down] gem to download response bodies into tempfiles, specifically
|
197
|
+
its [HTTP.rb] backend, as it supports changing the request method and uses an
|
198
|
+
order of magnitude less memory than the default backend. Notice that we didn't
|
199
|
+
have to download the original file from temporary storage as ImageOptim.com
|
200
|
+
allows us to provide a URL.
|
201
|
+
|
202
|
+
## Conditional processing
|
203
|
+
|
204
|
+
As we've seen, Shrine's processing API allows us to process files with regular
|
205
|
+
Ruby code. This means that we can make processing dynamic by using regular Ruby
|
206
|
+
conditionals.
|
207
|
+
|
208
|
+
For example, let's say we want our thumbnails to be either JPEGs or PNGs, and
|
209
|
+
we also want to save JPEGs as progressive (interlaced). Here's how the code for
|
210
|
+
this might look like:
|
211
|
+
|
212
|
+
```rb
|
213
|
+
require "image_processing/vips"
|
214
|
+
|
215
|
+
class ImageUploader < Shrine
|
216
|
+
plugin :processing
|
217
|
+
plugin :versions
|
218
|
+
plugin :delete_raw
|
219
|
+
|
220
|
+
process(:store) do |io, context|
|
221
|
+
original = io.download
|
222
|
+
pipeline = ImageProcessing::Vips.source(original)
|
223
|
+
|
224
|
+
# the `io` object contains the MIME type of the original file
|
225
|
+
if io.mime_type != "image/png"
|
226
|
+
pipeline = pipeline
|
227
|
+
.convert("jpeg")
|
228
|
+
.saver(interlace: true)
|
229
|
+
end
|
230
|
+
|
231
|
+
size_800 = pipeline.resize_to_limit!(800, 800)
|
232
|
+
size_500 = pipeline.resize_to_limit!(500, 500)
|
233
|
+
size_300 = pipeline.resize_to_limit!(300, 300)
|
234
|
+
|
235
|
+
original.close!
|
236
|
+
|
237
|
+
{ original: io, large: size_800, medium: size_500, small: size_300 }
|
238
|
+
end
|
239
|
+
end
|
240
|
+
```
|
241
|
+
|
242
|
+
## Processing other file types
|
243
|
+
|
244
|
+
So far we've only been talking about processing images. However, there is
|
245
|
+
nothing image-specific in Shrine's processing API, you can just as well process
|
246
|
+
any other types of files. The processing tool doesn't need to have any special
|
247
|
+
Shrine integration, the ImageProcessing gem that we saw earlier is a completely
|
248
|
+
generic gem.
|
249
|
+
|
250
|
+
To demonstrate, here is an example of transcoding videos using
|
251
|
+
[streamio-ffmpeg]:
|
252
|
+
|
253
|
+
```sh
|
254
|
+
$ brew install ffmpeg
|
255
|
+
```
|
256
|
+
|
257
|
+
```rb
|
258
|
+
# Gemfile
|
259
|
+
gem "streamio-ffmpeg"
|
260
|
+
```
|
261
|
+
|
262
|
+
```rb
|
263
|
+
require "streamio-ffmpeg"
|
264
|
+
|
265
|
+
class VideoUploader < Shrine
|
266
|
+
plugin :processing
|
267
|
+
plugin :versions
|
268
|
+
plugin :delete_raw
|
269
|
+
|
270
|
+
process(:store) do |io, context|
|
271
|
+
original = io.download
|
272
|
+
transcoded = Tempfile.new(["transcoded", ".mp4"], binmode: true)
|
273
|
+
screenshot = Tempfile.new(["screenshot", ".jpg"], binmode: true)
|
274
|
+
|
275
|
+
movie = FFMPEG::Movie.new(mov.path)
|
276
|
+
movie.transcode(transcoded.path)
|
277
|
+
movie.screenshot(screenshot.path)
|
278
|
+
|
279
|
+
[transcoded, screenshot].each(&:open) # refresh file descriptors
|
280
|
+
original.close!
|
281
|
+
|
282
|
+
{ original: io, transcoded: transcoded, screenshot: screenshot }
|
283
|
+
end
|
284
|
+
```
|
285
|
+
|
286
|
+
## On-the-fly processing
|
287
|
+
|
288
|
+
Generating image thumbnails on upload can be a pain to maintain, because
|
289
|
+
whenever you need to add a new version or change an existing one, you need to
|
290
|
+
perform this change for all existing uploads. [This guide][reprocessing
|
291
|
+
versions] explains the process in more detail.
|
292
|
+
|
293
|
+
As an alternative, it's very common to generate thumbnails dynamically, when
|
294
|
+
their URL is first requested, and then cache the processing result for future
|
295
|
+
requests. This strategy is known as "on-the-fly processing", and it's suitable
|
296
|
+
for smaller files such as images.
|
297
|
+
|
298
|
+
Shrine doesn't ship with on-the-fly processing functionality, as that's a
|
299
|
+
separate responsibility that belongs in its own project. There are various
|
300
|
+
open source solutions that provide this functionality:
|
301
|
+
|
302
|
+
* [Dragonfly]
|
303
|
+
* [imgproxy]
|
304
|
+
* [imaginary]
|
305
|
+
* [thumbor]
|
306
|
+
* [flyimg]
|
307
|
+
* ...
|
308
|
+
|
309
|
+
as well as many commercial solutions. To prove that you can really use them,
|
310
|
+
let's see how we can hook up [Dragonfly] with Shrine. We'll also see how we
|
311
|
+
can use [Cloudinary], as an example of a commercial solution.
|
312
|
+
|
313
|
+
### Dragonfly
|
314
|
+
|
315
|
+
Dragonfly is a mature file attachment library that comes with functionality for
|
316
|
+
on-the-fly processing. At first it might appear that Dragonfly can only be used
|
317
|
+
as an alternative to Shrine, but Dragonfly's app that performs on-the-fly
|
318
|
+
processing can actually be used standalone.
|
319
|
+
|
320
|
+
To set up Dragonfly, we'll insert its middleware that serves files and add
|
321
|
+
basic [configuration][Dragonfly configuration]:
|
322
|
+
|
323
|
+
```rb
|
324
|
+
Dragonfly.app.configure do
|
325
|
+
url_format "/attachments/:job"
|
326
|
+
secret "my secure secret" # used to generate the protective SHA
|
327
|
+
end
|
328
|
+
|
329
|
+
use Dragonfly::Middleware
|
330
|
+
```
|
331
|
+
|
332
|
+
If you're storing files in a cloud service like AWS S3, you should give them
|
333
|
+
public access so that you can generate non-expiring URLs. This way Dragonfly
|
334
|
+
URLs will not change and thus be cacheable, without having to use Dragonfly's
|
335
|
+
own S3 data store which requires pulling in [fog-aws].
|
336
|
+
|
337
|
+
To give new S3 objects public access, add `{ acl: "public-read" }` to upload
|
338
|
+
options (note that any existing S3 objects' ACLs will have to be manually
|
339
|
+
updated):
|
340
|
+
|
341
|
+
```rb
|
342
|
+
Shrine::Storage::S3.new(upload_options: { acl: "public-read" }, **other_options)
|
343
|
+
```
|
344
|
+
|
345
|
+
Now you can generate Dragonfly URLs from `Shrine::UploadedFile` objects:
|
346
|
+
|
347
|
+
```rb
|
348
|
+
def thumbnail_url(uploaded_file, dimensions)
|
349
|
+
Dragonfly.app
|
350
|
+
.fetch(uploaded_file.url(public: true))
|
351
|
+
.thumb(dimensions)
|
352
|
+
.url
|
353
|
+
end
|
354
|
+
```
|
355
|
+
```rb
|
356
|
+
thumbnail_url(photo.image, "500x400") #=> "/attachments/W1siZnUiLCJodHRwOi8vd3d3LnB1YmxpY2RvbWFpbn..."
|
357
|
+
```
|
358
|
+
|
359
|
+
### Cloudinary
|
360
|
+
|
361
|
+
[Cloudinary] is a nice service for on-the-fly image processing. The
|
362
|
+
[shrine-cloudinary] gem provides a Shrine storage that we can set for our
|
363
|
+
temporary and permanent storage:
|
364
|
+
|
365
|
+
```rb
|
366
|
+
# Gemfile
|
367
|
+
gem "shrine-cloudinary"
|
368
|
+
```
|
369
|
+
|
370
|
+
```rb
|
371
|
+
require "cloudinary"
|
372
|
+
require "shrine/storage/cloudinary"
|
373
|
+
|
374
|
+
Cloudinary.config(
|
375
|
+
cloud_name: "<YOUR_CLOUD_NAME>",
|
376
|
+
api_key: "<YOUR_API_KEY>",
|
377
|
+
api_secret: "<YOUR_API_SECRET>",
|
378
|
+
)
|
379
|
+
|
380
|
+
Shrine.storages = {
|
381
|
+
cache: Shrine::Storage::Cloudinary.new(prefix: "cache"),
|
382
|
+
store: Shrine::Storage::Cloudinary.new,
|
383
|
+
}
|
384
|
+
```
|
385
|
+
|
386
|
+
Now when we upload our images to Cloudinary, we can generate URLs with various
|
387
|
+
processing parameters:
|
388
|
+
|
389
|
+
```rb
|
390
|
+
photo.image.url(width: 100, height: 100, crop: :fit)
|
391
|
+
#=> "http://res.cloudinary.com/myapp/image/upload/w_100,h_100,c_fit/nature.jpg"
|
392
|
+
```
|
393
|
+
|
394
|
+
[`Shrine::UploadedFile`]: http://shrinerb.com/rdoc/classes/Shrine/Plugins/Base/FileMethods.html
|
395
|
+
[image_optim]: https://github.com/toy/image_optim
|
396
|
+
[ImageProcessing]: https://github.com/janko-m/image_processing
|
397
|
+
[`ImageProcessing::MiniMagick`]: https://github.com/janko-m/image_processing/blob/master/doc/minimagick.md
|
398
|
+
[ImageMagick]: https://www.imagemagick.org
|
399
|
+
[GraphicsMagick]: http://www.graphicsmagick.org
|
400
|
+
[libvips]: http://jcupitt.github.io/libvips/
|
401
|
+
[Why is libvips quick]: https://github.com/jcupitt/libvips/wiki/Why-is-libvips-quick
|
402
|
+
[ImageOptim.com]: https://imageoptim.com/api
|
403
|
+
[Down]: https://github.com/janko-m/down
|
404
|
+
[HTTP.rb]: https://github.com/httprb/http
|
405
|
+
[streamio-ffmpeg]: https://github.com/streamio/streamio-ffmpeg
|
406
|
+
[reprocessing versions]:http://shrinerb.com/rdoc/files/doc/regenerating_versions_md.html
|
407
|
+
[Dragonfly]: http://markevans.github.io/dragonfly/
|
408
|
+
[imgproxy]: https://github.com/DarthSim/imgproxy
|
409
|
+
[imaginary]: https://github.com/h2non/imaginary
|
410
|
+
[thumbor]: http://thumbor.org
|
411
|
+
[flyimg]: http://flyimg.io
|
412
|
+
[Cloudinary]: https://cloudinary.com
|
413
|
+
[Dragonfly configuration]: http://markevans.github.io/dragonfly/configuration
|
414
|
+
[fog-aws]: https://github.com/fog/fog-aws
|
415
|
+
[shrine-cloudinary]: https://github.com/shrinerb/shrine-cloudinary
|