shrine 3.8.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/doc/attacher.md +1 -1
- data/doc/design.md +3 -3
- data/doc/plugins/activerecord.md +91 -0
- data/doc/plugins/backgrounding.md +26 -0
- data/doc/plugins/dynamic_storage.md +15 -0
- data/doc/plugins/sequel.md +57 -0
- data/doc/plugins/tempfile.md +14 -6
- data/doc/plugins/upload_options.md +12 -0
- data/doc/plugins/url_options.md +12 -0
- data/doc/release_notes/3.9.0.md +42 -0
- data/lib/shrine/attacher.rb +13 -8
- data/lib/shrine/plugins/activerecord.rb +25 -4
- data/lib/shrine/plugins/backgrounding.rb +1 -1
- data/lib/shrine/plugins/column.rb +1 -1
- data/lib/shrine/plugins/derivatives.rb +6 -4
- data/lib/shrine/plugins/remove_invalid.rb +4 -0
- data/lib/shrine/plugins/sequel.rb +15 -1
- data/lib/shrine/plugins/tempfile.rb +0 -2
- data/lib/shrine/plugins/upload_options.rb +17 -3
- data/lib/shrine/plugins/url_options.rb +17 -3
- data/lib/shrine/version.rb +1 -1
- data/lib/shrine.rb +5 -1
- data/shrine.gemspec +2 -0
- metadata +49 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc3e1cb064bc4b1fc0132c5d97c244506b65014eac06b0e8181cf44b2319bf67
|
|
4
|
+
data.tar.gz: c289af323f41d3cd70dbe740439c9a08f595bba81150a396639cecd92b8b53af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 128cb224b8199aba9b4a24a0b911c90bc88e404fc9746b440b3edafe19b28422506edf67f4c01474701a9d9a56d5bc4a874152741239a8ff05bc1fa6449a6fb4
|
|
7
|
+
data.tar.gz: 2582f06b0c087d56cea3e9056bd0763a8d50613dc86bc2707593a3322da0d4be018a507189ca96b63f70715309d8f469f3bc536fcf4c5b3ab859d54697e9910b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 3.9.0 (2026-07-13)
|
|
2
|
+
|
|
3
|
+
* `Shrine.find_storage` now raises `Shrine::MissingStorage` (subclass of `Shrine::Error`) when the storage isn't registered, so it can be rescued separately (@janko)
|
|
4
|
+
|
|
5
|
+
* `url_options` – Allow regular expression storage keys for compatibility with `dynamic_storage` plugin (@janko)
|
|
6
|
+
|
|
7
|
+
* `upload_options` – Allow regular expression storage keys for compatibility with `dynamic_storage` plugin (@janko)
|
|
8
|
+
|
|
9
|
+
* `remove_invalid` – Fix model attribute not being cleared/reverted after deassigning invalid file (@janko)
|
|
10
|
+
|
|
11
|
+
* `activerecord`/`sequel` – Skip deleting the previous attachment when the record being saved was just created, to avoid deleting the original attachment of a duplicated record (@janko)
|
|
12
|
+
|
|
13
|
+
* `tempfile` – Allow `UploadedFile#tempfile` to be called without opening the uploaded file first (@janko)
|
|
14
|
+
|
|
15
|
+
* `activerecord` – Add `:attribute_types` option for skipping serialization of JSON/JSONB attributes declared via the Attributes API, not just database columns (@vojtad, @janko)
|
|
16
|
+
|
|
1
17
|
## 3.8.0 (2026-06-24)
|
|
2
18
|
|
|
3
19
|
* `s3` – Use single-request uploads for smaller files again instead of always using multipart uploads (@janko)
|
data/doc/attacher.md
CHANGED
|
@@ -189,7 +189,7 @@ attacher.upload(file, location: "path/to/file") # setting upload locat
|
|
|
189
189
|
|
|
190
190
|
### Changes
|
|
191
191
|
|
|
192
|
-
When a new file is attached, calling [`Attacher#finalize`](#
|
|
192
|
+
When a new file is attached, calling [`Attacher#finalize`](#finalizing) will
|
|
193
193
|
perform additional actions such as promotion and deleting any previous file.
|
|
194
194
|
It will also trigger [validation].
|
|
195
195
|
|
data/doc/design.md
CHANGED
|
@@ -9,8 +9,8 @@ There are five main types of classes that you deal with in Shrine:
|
|
|
9
9
|
|
|
10
10
|
| Class | Description |
|
|
11
11
|
| :---- | :---------- |
|
|
12
|
-
| [`Shrine::Storage::*`](#
|
|
13
|
-
| [`Shrine`](#
|
|
12
|
+
| [`Shrine::Storage::*`](#storage) | Manages files on a particular storage service |
|
|
13
|
+
| [`Shrine`](#shrine) | Wraps uploads and handles loading plugins |
|
|
14
14
|
| [`Shrine::UploadedFile`](#shrineuploadedfile) | Represents a file uploaded to a storage |
|
|
15
15
|
| [`Shrine::Attacher`](#shrineattacher) | Handles file attachment logic |
|
|
16
16
|
| [`Shrine::Attachment`](#shrineattachment) | Provides convenience model attachment interface |
|
|
@@ -60,7 +60,7 @@ end
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Storages are typically not used directly, but through [`Shrine`](#shrine) and
|
|
63
|
-
[`Shrine::UploadedFile`](#
|
|
63
|
+
[`Shrine::UploadedFile`](#shrineuploadedfile) classes.
|
|
64
64
|
|
|
65
65
|
## `Shrine`
|
|
66
66
|
|
data/doc/plugins/activerecord.md
CHANGED
|
@@ -80,6 +80,62 @@ Active Record currently has a [bug with transaction callbacks], so if you have
|
|
|
80
80
|
any "after commit" callbacks, make sure to include Shrine's attachment module
|
|
81
81
|
*after* they have all been defined.
|
|
82
82
|
|
|
83
|
+
#### Duplicating records
|
|
84
|
+
|
|
85
|
+
Since a record being created can't yet have a confirmed attachment of its own
|
|
86
|
+
to safely replace, Shrine never deletes the previous file when the attachment
|
|
87
|
+
changes as part of *creating* a record, only when *updating* one. This
|
|
88
|
+
matters most when duplicating a record: `#dup` performs a shallow copy, so a
|
|
89
|
+
duplicated record initially points to the *same* underlying file as the
|
|
90
|
+
original (they'll have the same attached file data), and replacing the
|
|
91
|
+
attachment on the duplicate before it's ever saved won't affect the original:
|
|
92
|
+
|
|
93
|
+
```rb
|
|
94
|
+
photo = Photo.create(image: file)
|
|
95
|
+
photo2 = photo.dup
|
|
96
|
+
|
|
97
|
+
photo2.update!(image: new_file) # replaces the attachment on `photo2`
|
|
98
|
+
photo.image.exists? #=> true (not affected)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Once `photo2` has been saved, replacing its attachment again behaves
|
|
102
|
+
normally (the previously attached file is deleted):
|
|
103
|
+
|
|
104
|
+
```rb
|
|
105
|
+
previous_image = photo2.image
|
|
106
|
+
photo2.update!(image: another_file)
|
|
107
|
+
previous_image.exists? #=> false
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Keep in mind this only protects against *replacing* the attachment on create.
|
|
111
|
+
As long as `photo` and `photo2` continue to reference the same underlying
|
|
112
|
+
file (i.e. `photo2` is saved without ever changing its attachment), destroying
|
|
113
|
+
either record will still delete the file the other one references, since
|
|
114
|
+
Shrine has no way of knowing the file is shared:
|
|
115
|
+
|
|
116
|
+
```rb
|
|
117
|
+
photo2.save! # still references the same file as `photo`
|
|
118
|
+
|
|
119
|
+
photo2.destroy
|
|
120
|
+
photo.image.exists? #=> false
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
If you want the duplicated record to have its own independent copy of the
|
|
124
|
+
file from the start, so that destroying either record is also safe, upload a
|
|
125
|
+
new copy explicitly after duplicating:
|
|
126
|
+
|
|
127
|
+
```rb
|
|
128
|
+
photo = Photo.create(image: file)
|
|
129
|
+
photo2 = photo.dup
|
|
130
|
+
|
|
131
|
+
photo2.image_attacher.set(nil)
|
|
132
|
+
photo2.image_attacher.attach(photo.image, storage: photo.image.storage_key)
|
|
133
|
+
photo2.save!
|
|
134
|
+
|
|
135
|
+
photo2.destroy # no longer affects `photo`
|
|
136
|
+
photo.image.exists? #=> true
|
|
137
|
+
```
|
|
138
|
+
|
|
83
139
|
#### Overriding callbacks
|
|
84
140
|
|
|
85
141
|
You can override any of the following attacher methods to modify callback
|
|
@@ -177,6 +233,39 @@ model errors, you can set `:validations` to `false`:
|
|
|
177
233
|
plugin :activerecord, validations: false
|
|
178
234
|
```
|
|
179
235
|
|
|
236
|
+
### JSON columns
|
|
237
|
+
|
|
238
|
+
When the data attribute is backed by a `json` or `jsonb` database column,
|
|
239
|
+
Active Record already serializes hashes into JSON, so Shrine skips its own
|
|
240
|
+
serialization to avoid double encoding.
|
|
241
|
+
|
|
242
|
+
```rb
|
|
243
|
+
class Photo < ActiveRecord::Base # `image_data` is a jsonb column
|
|
244
|
+
include ImageUploader::Attachment(:image)
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
```rb
|
|
248
|
+
photo.image = file
|
|
249
|
+
photo.image_data #=> { "id" => "bc2e13.jpg", "storage" => "cache", "metadata" => { ... } }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
By default this detection only looks at real database columns. If you declare
|
|
253
|
+
the data attribute type via the [Attributes API] (e.g. on top of a `text`
|
|
254
|
+
column), enable the `:attribute_types` option so it's recognized as well:
|
|
255
|
+
|
|
256
|
+
```rb
|
|
257
|
+
plugin :activerecord, attribute_types: true
|
|
258
|
+
```
|
|
259
|
+
```rb
|
|
260
|
+
class Photo < ActiveRecord::Base # `image_data` is a text column
|
|
261
|
+
include ImageUploader::Attachment(:image)
|
|
262
|
+
|
|
263
|
+
attribute :image_data, :json
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
This option defaults to `false` for backwards compatibility.
|
|
268
|
+
|
|
180
269
|
## Attacher
|
|
181
270
|
|
|
182
271
|
You can also use `Shrine::Attacher` directly (with or without the
|
|
@@ -217,8 +306,10 @@ See [persistence] docs for more details.
|
|
|
217
306
|
|
|
218
307
|
[activerecord]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/activerecord.rb
|
|
219
308
|
[Active Record]: https://guides.rubyonrails.org/active_record_basics.html
|
|
309
|
+
[Attributes API]: https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute
|
|
220
310
|
[model]: https://shrinerb.com/docs/plugins/model
|
|
221
311
|
[callbacks]: https://guides.rubyonrails.org/active_record_callbacks.html
|
|
222
312
|
[bug with transaction callbacks]: https://github.com/rails/rails/issues/14493
|
|
223
313
|
[validation]: https://shrinerb.com/docs/plugins/validation
|
|
224
314
|
[persistence]: https://shrinerb.com/docs/plugins/persistence
|
|
315
|
+
[Replacing]: https://shrinerb.com/docs/attacher#replacing
|
|
@@ -203,6 +203,32 @@ class MyUploader < Shrine
|
|
|
203
203
|
end
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
+
## Testing
|
|
207
|
+
|
|
208
|
+
Since promotion happens in a background job, it won't have run yet
|
|
209
|
+
immediately after you save the record, so asserting on the promoted file's
|
|
210
|
+
data or location will fail unless the job has actually been executed. For
|
|
211
|
+
example, with Active Job you'll need to run enqueued jobs inline:
|
|
212
|
+
|
|
213
|
+
```rb
|
|
214
|
+
require "active_job/test_helper"
|
|
215
|
+
|
|
216
|
+
include ActiveJob::TestHelper
|
|
217
|
+
|
|
218
|
+
perform_enqueued_jobs do
|
|
219
|
+
photo = Photo.create(image: file) # spawns promote job
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
photo.reload # fetch attachment data updated by the background job
|
|
223
|
+
photo.image.storage_key #=> :store
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Keep in mind that while the file is cached (i.e. before the promote job has
|
|
227
|
+
run), the record may not have an `id` yet, so plugins like [`pretty_location`]
|
|
228
|
+
that build the storage location from the record's identifier won't be able
|
|
229
|
+
to include it until promotion happens.
|
|
230
|
+
|
|
206
231
|
[backgrounding]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/backgrounding.rb
|
|
232
|
+
[`pretty_location`]: https://shrinerb.com/docs/plugins/pretty_location
|
|
207
233
|
[derivatives]: https://shrinerb.com/docs/plugins/derivatives
|
|
208
234
|
[atomic_helpers]: https://shrinerb.com/docs/plugins/atomic_helpers
|
|
@@ -22,4 +22,19 @@ the bucket "foo". The block is yielded an instance of `MatchData`.
|
|
|
22
22
|
|
|
23
23
|
This can be useful in combination with the `default_storage` plugin.
|
|
24
24
|
|
|
25
|
+
If the resolver block returns `nil` (e.g. it looks up a record that no longer
|
|
26
|
+
exists), `Shrine.find_storage` raises `Shrine::MissingStorage`, which can be
|
|
27
|
+
rescued separately from other `Shrine::Error` subclasses:
|
|
28
|
+
|
|
29
|
+
```rb
|
|
30
|
+
storage /store_(\w+)/ do |match|
|
|
31
|
+
Library.find_by(id: match[1])&.storage
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```rb
|
|
36
|
+
attacher.file.storage
|
|
37
|
+
# => raises Shrine::MissingStorage if the Library record was deleted
|
|
38
|
+
```
|
|
39
|
+
|
|
25
40
|
[dynamic_storage]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/dynamic_storage.rb
|
data/doc/plugins/sequel.md
CHANGED
|
@@ -100,6 +100,63 @@ set `:hooks` to `false`:
|
|
|
100
100
|
plugin :sequel, hooks: false
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
#### Duplicating records
|
|
104
|
+
|
|
105
|
+
Since a record being created can't yet have a confirmed attachment of its own
|
|
106
|
+
to safely replace, Shrine never deletes the previous file when the attachment
|
|
107
|
+
changes as part of *creating* a record, only when *updating* one.
|
|
108
|
+
|
|
109
|
+
Note that Sequel's `#dup`/`#clone`, unlike Active Record's, don't reset the
|
|
110
|
+
primary key or persistence state — a duplicated record still refers to the
|
|
111
|
+
*same* row, so saving it just updates that row rather than inserting a new
|
|
112
|
+
one. There's only ever one row here, so there's nothing to protect:
|
|
113
|
+
|
|
114
|
+
```rb
|
|
115
|
+
photo = Photo.create(image: file)
|
|
116
|
+
photo2 = photo.dup # `photo2` refers to the same row as `photo`
|
|
117
|
+
|
|
118
|
+
photo2.update(image: new_file)
|
|
119
|
+
photo.image.exists? #=> false (the row now has `new_file`, so this is expected)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The common way to duplicate a record in Sequel is instead to construct a new
|
|
123
|
+
one from the original's values, which *does* produce a genuinely new,
|
|
124
|
+
unpersisted row, and so *is* protected:
|
|
125
|
+
|
|
126
|
+
```rb
|
|
127
|
+
photo2 = Photo.new(photo.values.except(:id))
|
|
128
|
+
|
|
129
|
+
photo2.update(image: new_file)
|
|
130
|
+
photo.image.exists? #=> true (not affected)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Keep in mind this only protects against *replacing* the attachment on create.
|
|
134
|
+
As long as `photo` and `photo2` continue to reference the same underlying
|
|
135
|
+
file (i.e. `photo2` is saved without ever changing its attachment),
|
|
136
|
+
destroying either record will still delete the file the other one
|
|
137
|
+
references, since Shrine has no way of knowing the file is shared:
|
|
138
|
+
|
|
139
|
+
```rb
|
|
140
|
+
photo2.save # still references the same file as `photo`
|
|
141
|
+
|
|
142
|
+
photo2.destroy
|
|
143
|
+
photo.image.exists? #=> false
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
If you want `photo2` to have its own independent copy of the file from the
|
|
147
|
+
start, so that destroying either record is also safe, upload a new copy
|
|
148
|
+
explicitly:
|
|
149
|
+
|
|
150
|
+
```rb
|
|
151
|
+
photo2 = Photo.new(photo.values.reject { |k, _| k == :id })
|
|
152
|
+
photo2.image_attacher.set(nil)
|
|
153
|
+
photo2.image_attacher.attach(photo.image, storage: photo.image.storage_key)
|
|
154
|
+
photo2.save
|
|
155
|
+
|
|
156
|
+
photo2.destroy # no longer affects `photo`
|
|
157
|
+
photo.image.exists? #=> true
|
|
158
|
+
```
|
|
159
|
+
|
|
103
160
|
### Validations
|
|
104
161
|
|
|
105
162
|
If you're using the [`validation`][validation] plugin, the attachment module
|
data/doc/plugins/tempfile.md
CHANGED
|
@@ -9,14 +9,22 @@ uploaded file on disk.
|
|
|
9
9
|
Shrine.plugin :tempfile
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
The plugin provides the `UploadedFile#tempfile` method, which
|
|
13
|
-
|
|
14
|
-
the
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
closed
|
|
12
|
+
The plugin provides the `UploadedFile#tempfile` method, which returns a copy
|
|
13
|
+
of the uploaded file's content on disk. The first time the method is called
|
|
14
|
+
the file content will be downloaded into a temporary file and returned. On
|
|
15
|
+
any subsequent method calls the cached temporary file will be returned
|
|
16
|
+
directly. If the uploaded file is currently open, its tempfile is deleted
|
|
17
|
+
when the uploaded file is closed; otherwise it's deleted whenever it becomes
|
|
18
|
+
unreachable and is garbage collected (so it's still recommended to close the
|
|
19
|
+
uploaded file when you're done with it, to have the tempfile cleaned up
|
|
20
|
+
deterministically).
|
|
18
21
|
|
|
19
22
|
```rb
|
|
23
|
+
uploaded_file.tempfile #=> #<Tempfile:...> (file is downloaded and cached)
|
|
24
|
+
uploaded_file.tempfile #=> #<Tempfile:...> (cache is returned)
|
|
25
|
+
|
|
26
|
+
# OR
|
|
27
|
+
|
|
20
28
|
uploaded_file.open do
|
|
21
29
|
# ...
|
|
22
30
|
uploaded_file.tempfile #=> #<Tempfile:...> (file is cached)
|
|
@@ -29,4 +29,16 @@ the uploader.
|
|
|
29
29
|
uploader.upload(file, upload_options: { acl: "public-read" })
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
If storage keys are generated dynamically (e.g. via the
|
|
33
|
+
[`dynamic_storage`][dynamic_storage] plugin), it's not possible to list every
|
|
34
|
+
storage key upfront. In that case you can use a `Regexp` instead, which will
|
|
35
|
+
be matched against the storage key:
|
|
36
|
+
|
|
37
|
+
```rb
|
|
38
|
+
plugin :upload_options, /_store\z/ => { acl: "private" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
An exact storage key match always takes precedence over a `Regexp` match.
|
|
42
|
+
|
|
32
43
|
[upload_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/upload_options.rb
|
|
44
|
+
[dynamic_storage]: https://shrinerb.com/docs/plugins/dynamic_storage
|
data/doc/plugins/url_options.md
CHANGED
|
@@ -24,4 +24,16 @@ In both cases the default options are merged with options passed to
|
|
|
24
24
|
`UploadedFile#url`, and the latter will always have precedence over default
|
|
25
25
|
options.
|
|
26
26
|
|
|
27
|
+
If storage keys are generated dynamically (e.g. via the
|
|
28
|
+
[`dynamic_storage`][dynamic_storage] plugin), it's not possible to list every
|
|
29
|
+
storage key upfront. In that case you can use a `Regexp` instead, which will
|
|
30
|
+
be matched against the storage key:
|
|
31
|
+
|
|
32
|
+
```rb
|
|
33
|
+
plugin :url_options, /_store\z/ => { expires_in: 24*60*60 }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
An exact storage key match always takes precedence over a `Regexp` match.
|
|
37
|
+
|
|
27
38
|
[url_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/url_options.rb
|
|
39
|
+
[dynamic_storage]: https://shrinerb.com/docs/plugins/dynamic_storage
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Shrine 3.9.0
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## New features
|
|
6
|
+
|
|
7
|
+
* `Shrine.find_storage` now raises `Shrine::MissingStorage` (a subclass of `Shrine::Error`) when the storage isn't registered, instead of a generic `Shrine::Error`. This is useful with the `dynamic_storage` plugin, where the resolver might fail to find its dependencies (e.g. a deleted DB record), and lets you rescue that case separately from other errors.
|
|
8
|
+
|
|
9
|
+
```rb
|
|
10
|
+
begin
|
|
11
|
+
Shrine.find_storage(:store)
|
|
12
|
+
rescue Shrine::MissingStorage
|
|
13
|
+
# handle missing storage specifically
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
* The `url_options` and `upload_options` plugins now accept a `Regexp` in place of a storage key. This is useful when storage keys are generated dynamically (e.g. via the `dynamic_storage` plugin), since it's not possible to list every storage key upfront. An exact storage key match always takes precedence over a `Regexp` match.
|
|
18
|
+
|
|
19
|
+
```rb
|
|
20
|
+
plugin :url_options, /_store\z/ => { expires_in: 24*60*60 }
|
|
21
|
+
plugin :upload_options, /_store\z/ => { acl: "private" }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
* The `activerecord` plugin now accepts an `:attribute_types` option. When enabled, it uses `type_for_attribute` (instead of just `columns_hash`) to detect whether the data attribute is JSON/JSONB, so Shrine also skips its own serialization for attributes declared via the Active Record Attributes API (not just real database columns). It defaults to `false` for backwards compatibility with apps that might be relying on the previous double-serialization behavior.
|
|
25
|
+
|
|
26
|
+
```rb
|
|
27
|
+
class Photo < ActiveRecord::Base # `image_data` is a text column
|
|
28
|
+
include ImageUploader::Attachment(:image)
|
|
29
|
+
|
|
30
|
+
attribute :image_data, :json
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
plugin :activerecord, attribute_types: true
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Bug fixes
|
|
37
|
+
|
|
38
|
+
* The `activerecord` and `sequel` plugins no longer delete the previous attachment when the record being saved was just created. Previously, duplicating a persisted record (e.g. via `#dup`) and immediately assigning a new attachment before saving the duplicate could end up deleting the *original* record's attachment, since both records shared the same underlying file.
|
|
39
|
+
|
|
40
|
+
* The `remove_invalid` plugin now correctly clears/reverts the model attribute after an invalid file is deassigned. Previously the record's raw attribute could be left out of sync with the attacher after validation failed.
|
|
41
|
+
|
|
42
|
+
* `UploadedFile#tempfile` (from the `tempfile` plugin) can now be called without opening the uploaded file first. Previously it raised `Shrine::Error` unless the file was already open; now it downloads and caches the file on demand. If the uploaded file is open, its tempfile is still cleaned up when the file is closed; otherwise it's cleaned up whenever it becomes unreachable and is garbage collected, so it's still recommended to close the uploaded file when you're done with it for deterministic cleanup.
|
data/lib/shrine/attacher.rb
CHANGED
|
@@ -20,7 +20,7 @@ class Shrine
|
|
|
20
20
|
|
|
21
21
|
# Initializes the attacher from a data hash generated from `Attacher#data`.
|
|
22
22
|
#
|
|
23
|
-
# attacher = Attacher.from_data({ "id" => "...", "storage" => "...", "metadata" => {
|
|
23
|
+
# attacher = Attacher.from_data({ "id" => "...", "storage" => "...", "metadata" => {} })
|
|
24
24
|
# attacher.file #=> #<Shrine::UploadedFile>
|
|
25
25
|
def from_data(data, **)
|
|
26
26
|
attacher = new(**)
|
|
@@ -60,8 +60,8 @@ class Shrine
|
|
|
60
60
|
# useful when the uploaded file comes from form fields). Forwards any
|
|
61
61
|
# additional options to #attach_cached.
|
|
62
62
|
#
|
|
63
|
-
# attacher.assign(File.open(
|
|
64
|
-
# attacher.assign(File.open(
|
|
63
|
+
# attacher.assign(File.open(path))
|
|
64
|
+
# attacher.assign(File.open(path), metadata: { "foo" => "bar" })
|
|
65
65
|
# attacher.assign('{"id":"...","storage":"cache","metadata":{...}}')
|
|
66
66
|
# attacher.assign({ "id" => "...", "storage" => "cache", "metadata" => {} })
|
|
67
67
|
#
|
|
@@ -82,10 +82,10 @@ class Shrine
|
|
|
82
82
|
# #attach.
|
|
83
83
|
#
|
|
84
84
|
# # upload file to temporary storage and set the uploaded file.
|
|
85
|
-
# attacher.attach_cached(File.open(
|
|
85
|
+
# attacher.attach_cached(File.open(path))
|
|
86
86
|
#
|
|
87
87
|
# # foward additional options to the uploader
|
|
88
|
-
# attacher.attach_cached(File.open(
|
|
88
|
+
# attacher.attach_cached(File.open(path), metadata: { "foo" => "bar" })
|
|
89
89
|
#
|
|
90
90
|
# # sets an existing cached file from JSON data
|
|
91
91
|
# attacher.attach_cached('{"id":"...","storage":"cache","metadata":{...}}')
|
|
@@ -189,7 +189,7 @@ class Shrine
|
|
|
189
189
|
# attacher.destroy_previous
|
|
190
190
|
# previous_file.exists? #=> false
|
|
191
191
|
def destroy_previous
|
|
192
|
-
@previous.destroy_attached if changed?
|
|
192
|
+
@previous.destroy_attached if changed? && destroy_previous?
|
|
193
193
|
end
|
|
194
194
|
|
|
195
195
|
# Destroys the attached file if it exists and is uploaded to permanent
|
|
@@ -299,7 +299,7 @@ class Shrine
|
|
|
299
299
|
# Loads the uploaded file from data generated by `Attacher#data`.
|
|
300
300
|
#
|
|
301
301
|
# attacher.file #=> nil
|
|
302
|
-
# attacher.load_data({ "id" => "...", "storage" => "...", "metadata" => {
|
|
302
|
+
# attacher.load_data({ "id" => "...", "storage" => "...", "metadata" => {} })
|
|
303
303
|
# attacher.file #=> #<Shrine::UploadedFile>
|
|
304
304
|
def load_data(data)
|
|
305
305
|
@file = data && uploaded_file(data)
|
|
@@ -356,7 +356,7 @@ class Shrine
|
|
|
356
356
|
# #=> #<Shrine::UploadedFile>
|
|
357
357
|
#
|
|
358
358
|
# # from Hash data
|
|
359
|
-
# attacher.cached({ "id" => "...", "storage" => "cache", "metadata" => {
|
|
359
|
+
# attacher.cached({ "id" => "...", "storage" => "cache", "metadata" => {} })
|
|
360
360
|
# #=> #<Shrine::UploadedFile>
|
|
361
361
|
def cached(value, **)
|
|
362
362
|
uploaded_file = uploaded_file(value)
|
|
@@ -380,6 +380,11 @@ class Shrine
|
|
|
380
380
|
attached? && !cached?
|
|
381
381
|
end
|
|
382
382
|
|
|
383
|
+
# Whether the previous attached file should be deleted.
|
|
384
|
+
def destroy_previous?
|
|
385
|
+
true
|
|
386
|
+
end
|
|
387
|
+
|
|
383
388
|
# Whether assigning the given file is considered a change.
|
|
384
389
|
def change?(file)
|
|
385
390
|
@file != file
|
|
@@ -12,7 +12,7 @@ class Shrine
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def self.configure(uploader, **opts)
|
|
15
|
-
uploader.opts[:activerecord] ||= { callbacks: true, validations: true }
|
|
15
|
+
uploader.opts[:activerecord] ||= { callbacks: true, validations: true, attribute_types: false }
|
|
16
16
|
uploader.opts[:activerecord].merge!(opts)
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -108,12 +108,22 @@ class Shrine
|
|
|
108
108
|
record.transaction { yield record.clone.reload(lock: true) }
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Returns true if the data attribute represents a JSON or JSONB
|
|
111
|
+
# Returns true if the data attribute represents a JSON or JSONB type.
|
|
112
112
|
# Used by the _persistence plugin to determine whether serialization
|
|
113
113
|
# should be skipped.
|
|
114
|
+
#
|
|
115
|
+
# By default only real database columns are checked. When the
|
|
116
|
+
# `:attribute_types` plugin option is enabled, the check uses
|
|
117
|
+
# `type_for_attribute`, so attributes declared via the Attributes API
|
|
118
|
+
# are recognized as well.
|
|
114
119
|
def activerecord_hash_attribute?
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
if shrine_class.opts[:activerecord][:attribute_types]
|
|
121
|
+
attribute_type = record.class.type_for_attribute(attribute.to_s)
|
|
122
|
+
attribute_type && [:json, :jsonb].include?(attribute_type.type)
|
|
123
|
+
else
|
|
124
|
+
column = record.class.columns_hash[attribute.to_s]
|
|
125
|
+
column && [:json, :jsonb].include?(column.type)
|
|
126
|
+
end
|
|
117
127
|
end
|
|
118
128
|
|
|
119
129
|
# Returns whether the record is an ActiveRecord model. Used by the
|
|
@@ -121,6 +131,17 @@ class Shrine
|
|
|
121
131
|
def activerecord?
|
|
122
132
|
record.is_a?(::ActiveRecord::Base)
|
|
123
133
|
end
|
|
134
|
+
|
|
135
|
+
# Prevents the previous file from being destroyed when this save
|
|
136
|
+
# created the record (as opposed to updating an existing one), since
|
|
137
|
+
# a record being created can't yet have its own confirmed attachment
|
|
138
|
+
# to safely replace (e.g. when it was duplicated via `#dup` from
|
|
139
|
+
# another, still-persisted record).
|
|
140
|
+
def destroy_previous?
|
|
141
|
+
return super unless activerecord?
|
|
142
|
+
|
|
143
|
+
super && !record.previously_new_record?
|
|
144
|
+
end
|
|
124
145
|
end
|
|
125
146
|
end
|
|
126
147
|
|
|
@@ -54,7 +54,7 @@ class Shrine
|
|
|
54
54
|
# Converts the column data hash into a string (generates JSON by
|
|
55
55
|
# default).
|
|
56
56
|
#
|
|
57
|
-
# Attacher.serialize_column({ "id" => "...", "storage" => "...", "metadata" => {
|
|
57
|
+
# Attacher.serialize_column({ "id" => "...", "storage" => "...", "metadata" => {} })
|
|
58
58
|
# #=> '{"id":"...","storage":"...","metadata":{...}}'
|
|
59
59
|
#
|
|
60
60
|
# Attacher.serialize_column(nil)
|
|
@@ -431,12 +431,12 @@ class Shrine
|
|
|
431
431
|
# attacher.load_data({
|
|
432
432
|
# "id" => "...",
|
|
433
433
|
# "storage" => "store",
|
|
434
|
-
# "metadata" => {
|
|
434
|
+
# "metadata" => {},
|
|
435
435
|
# "derivatives" => {
|
|
436
436
|
# "thumb" => {
|
|
437
437
|
# "id" => "...",
|
|
438
438
|
# "storage" => "store",
|
|
439
|
-
# "metadata" => {
|
|
439
|
+
# "metadata" => {},
|
|
440
440
|
# }
|
|
441
441
|
# }
|
|
442
442
|
# })
|
|
@@ -471,7 +471,7 @@ class Shrine
|
|
|
471
471
|
|
|
472
472
|
# Sets a hash of derivatives.
|
|
473
473
|
#
|
|
474
|
-
# attacher.derivatives = { thumb: Shrine.uploaded_file(
|
|
474
|
+
# attacher.derivatives = { thumb: Shrine.uploaded_file(data) }
|
|
475
475
|
# attacher.derivatives #=> { thumb: #<Shrine::UploadedFile ...> }
|
|
476
476
|
def derivatives=(derivatives)
|
|
477
477
|
unless derivatives.is_a?(Hash)
|
|
@@ -483,7 +483,9 @@ class Shrine
|
|
|
483
483
|
|
|
484
484
|
# Iterates through nested derivatives and maps results.
|
|
485
485
|
#
|
|
486
|
-
# attacher.map_derivative(derivatives)
|
|
486
|
+
# attacher.map_derivative(derivatives) do |path, file|
|
|
487
|
+
# # ...
|
|
488
|
+
# end
|
|
487
489
|
def map_derivative(derivatives, **options, &block)
|
|
488
490
|
shrine_class.map_derivative(derivatives, **options, &block)
|
|
489
491
|
end
|
|
@@ -87,8 +87,11 @@ class Shrine
|
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Calls Attacher#save. Called before model save.
|
|
90
|
+
# Calls Attacher#save. Called before model save. Remembers whether
|
|
91
|
+
# the record is new, since by the time `#sequel_after_save` runs,
|
|
92
|
+
# Sequel has already flipped `#new?` to false.
|
|
91
93
|
def sequel_before_save
|
|
94
|
+
@previously_new_record = record.new?
|
|
92
95
|
save
|
|
93
96
|
end
|
|
94
97
|
|
|
@@ -132,6 +135,17 @@ class Shrine
|
|
|
132
135
|
def sequel?
|
|
133
136
|
record.is_a?(::Sequel::Model)
|
|
134
137
|
end
|
|
138
|
+
|
|
139
|
+
# Prevents the previous file from being destroyed when this save
|
|
140
|
+
# created the record (as opposed to updating an existing one), since
|
|
141
|
+
# a record being created can't yet have its own confirmed attachment
|
|
142
|
+
# to safely replace (e.g. when it was duplicated from another,
|
|
143
|
+
# still-persisted record).
|
|
144
|
+
def destroy_previous?
|
|
145
|
+
return super unless sequel?
|
|
146
|
+
|
|
147
|
+
super && !@previously_new_record
|
|
148
|
+
end
|
|
135
149
|
end
|
|
136
150
|
end
|
|
137
151
|
|
|
@@ -4,9 +4,9 @@ class Shrine
|
|
|
4
4
|
module Plugins
|
|
5
5
|
# Documentation can be found on https://shrinerb.com/docs/plugins/upload_options
|
|
6
6
|
module UploadOptions
|
|
7
|
-
def self.configure(uploader,
|
|
7
|
+
def self.configure(uploader, options = {})
|
|
8
8
|
uploader.opts[:upload_options] ||= {}
|
|
9
|
-
uploader.opts[:upload_options].merge!(
|
|
9
|
+
uploader.opts[:upload_options].merge!(options)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
module InstanceMethods
|
|
@@ -19,11 +19,25 @@ class Shrine
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def get_upload_options(io, options)
|
|
22
|
-
upload_options =
|
|
22
|
+
upload_options = find_upload_options || {}
|
|
23
23
|
upload_options = upload_options.call(io, options) if upload_options.respond_to?(:call)
|
|
24
24
|
upload_options = upload_options.merge(options[:upload_options]) if options[:upload_options]
|
|
25
25
|
upload_options
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Matches the storage key exactly first, then falls back to any
|
|
29
|
+
# registered regex that matches the storage key. The regex form is
|
|
30
|
+
# useful when storage keys are generated dynamically (e.g. via the
|
|
31
|
+
# `dynamic_storage` plugin), since it's not possible to list every
|
|
32
|
+
# storage key upfront.
|
|
33
|
+
def find_upload_options
|
|
34
|
+
upload_options = opts[:upload_options]
|
|
35
|
+
|
|
36
|
+
return upload_options[storage_key] if upload_options.key?(storage_key)
|
|
37
|
+
|
|
38
|
+
_, options = upload_options.find { |key, _| key.is_a?(Regexp) && key.match?(storage_key.to_s) }
|
|
39
|
+
options
|
|
40
|
+
end
|
|
27
41
|
end
|
|
28
42
|
end
|
|
29
43
|
|
|
@@ -4,9 +4,9 @@ class Shrine
|
|
|
4
4
|
module Plugins
|
|
5
5
|
# Documentation can be found on https://shrinerb.com/docs/plugins/url_options
|
|
6
6
|
module UrlOptions
|
|
7
|
-
def self.configure(uploader,
|
|
7
|
+
def self.configure(uploader, options = {})
|
|
8
8
|
uploader.opts[:url_options] ||= {}
|
|
9
|
-
uploader.opts[:url_options].merge!(
|
|
9
|
+
uploader.opts[:url_options].merge!(options)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
module FileMethods
|
|
@@ -19,10 +19,24 @@ class Shrine
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def url_options(options)
|
|
22
|
-
default_options =
|
|
22
|
+
default_options = find_url_options
|
|
23
23
|
default_options = default_options.call(self, options) if default_options.respond_to?(:call)
|
|
24
24
|
default_options || {}
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# Matches the storage key exactly first, then falls back to any
|
|
28
|
+
# registered regex that matches the storage key. The regex form is
|
|
29
|
+
# useful when storage keys are generated dynamically (e.g. via the
|
|
30
|
+
# `dynamic_storage` plugin), since it's not possible to list every
|
|
31
|
+
# storage key upfront.
|
|
32
|
+
def find_url_options
|
|
33
|
+
url_options = shrine_class.opts[:url_options]
|
|
34
|
+
|
|
35
|
+
return url_options[storage_key] if url_options.key?(storage_key)
|
|
36
|
+
|
|
37
|
+
_, options = url_options.find { |key, _| key.is_a?(Regexp) && key.match?(storage_key.to_s) }
|
|
38
|
+
options
|
|
39
|
+
end
|
|
26
40
|
end
|
|
27
41
|
end
|
|
28
42
|
|
data/lib/shrine/version.rb
CHANGED
data/lib/shrine.rb
CHANGED
|
@@ -28,6 +28,10 @@ class Shrine
|
|
|
28
28
|
class FileNotFound < Error
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Raised by Shrine.find_storage when the storage isn't registered.
|
|
32
|
+
class MissingStorage < Error
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
@opts = {}
|
|
32
36
|
@storages = {}
|
|
33
37
|
@logger = Logger.new(STDOUT)
|
|
@@ -86,7 +90,7 @@ class Shrine
|
|
|
86
90
|
# Retrieves the storage under the given identifier (can be a Symbol or
|
|
87
91
|
# a String), raising Shrine::Error if the storage is missing.
|
|
88
92
|
def find_storage(name)
|
|
89
|
-
storages[name.to_sym] || storages[name.to_s] or fail
|
|
93
|
+
storages[name.to_sym] || storages[name.to_s] or fail MissingStorage, "storage #{name.inspect} isn't registered on #{self}"
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
# Generates an instance of Shrine::Attachment to be included in the
|
data/shrine.gemspec
CHANGED
|
@@ -36,6 +36,8 @@ direct uploads for fully asynchronous user experience.
|
|
|
36
36
|
gem.add_dependency "down", "~> 5.1"
|
|
37
37
|
gem.add_dependency "content_disposition", "~> 1.0"
|
|
38
38
|
|
|
39
|
+
gem.add_development_dependency "rdoc", "~> 8.0" unless RUBY_ENGINE == "jruby"
|
|
40
|
+
|
|
39
41
|
# general testing helpers
|
|
40
42
|
gem.add_development_dependency "rake", ">= 11.1"
|
|
41
43
|
gem.add_development_dependency "minitest", "~> 6.0"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shrine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Janko Marohnić
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rdoc
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '8.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '8.0'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: rake
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -155,6 +169,20 @@ dependencies:
|
|
|
155
169
|
- - ">="
|
|
156
170
|
- !ruby/object:Gem::Version
|
|
157
171
|
version: '0'
|
|
172
|
+
- !ruby/object:Gem::Dependency
|
|
173
|
+
name: ruby-filemagic
|
|
174
|
+
requirement: !ruby/object:Gem::Requirement
|
|
175
|
+
requirements:
|
|
176
|
+
- - "~>"
|
|
177
|
+
- !ruby/object:Gem::Version
|
|
178
|
+
version: '0.7'
|
|
179
|
+
type: :development
|
|
180
|
+
prerelease: false
|
|
181
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
182
|
+
requirements:
|
|
183
|
+
- - "~>"
|
|
184
|
+
- !ruby/object:Gem::Version
|
|
185
|
+
version: '0.7'
|
|
158
186
|
- !ruby/object:Gem::Dependency
|
|
159
187
|
name: mime-types
|
|
160
188
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -287,14 +315,14 @@ dependencies:
|
|
|
287
315
|
requirements:
|
|
288
316
|
- - "~>"
|
|
289
317
|
- !ruby/object:Gem::Version
|
|
290
|
-
version: 8.
|
|
318
|
+
version: '8.1'
|
|
291
319
|
type: :development
|
|
292
320
|
prerelease: false
|
|
293
321
|
version_requirements: !ruby/object:Gem::Requirement
|
|
294
322
|
requirements:
|
|
295
323
|
- - "~>"
|
|
296
324
|
- !ruby/object:Gem::Version
|
|
297
|
-
version: 8.
|
|
325
|
+
version: '8.1'
|
|
298
326
|
- !ruby/object:Gem::Dependency
|
|
299
327
|
name: sequel
|
|
300
328
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -315,14 +343,28 @@ dependencies:
|
|
|
315
343
|
requirements:
|
|
316
344
|
- - "~>"
|
|
317
345
|
- !ruby/object:Gem::Version
|
|
318
|
-
version: 8.
|
|
346
|
+
version: '8.1'
|
|
319
347
|
type: :development
|
|
320
348
|
prerelease: false
|
|
321
349
|
version_requirements: !ruby/object:Gem::Requirement
|
|
322
350
|
requirements:
|
|
323
351
|
- - "~>"
|
|
324
352
|
- !ruby/object:Gem::Version
|
|
325
|
-
version: 8.
|
|
353
|
+
version: '8.1'
|
|
354
|
+
- !ruby/object:Gem::Dependency
|
|
355
|
+
name: sqlite3
|
|
356
|
+
requirement: !ruby/object:Gem::Requirement
|
|
357
|
+
requirements:
|
|
358
|
+
- - "~>"
|
|
359
|
+
- !ruby/object:Gem::Version
|
|
360
|
+
version: '2.1'
|
|
361
|
+
type: :development
|
|
362
|
+
prerelease: false
|
|
363
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
364
|
+
requirements:
|
|
365
|
+
- - "~>"
|
|
366
|
+
- !ruby/object:Gem::Version
|
|
367
|
+
version: '2.1'
|
|
326
368
|
description: |
|
|
327
369
|
Shrine is a toolkit for file attachments in Ruby applications. It supports
|
|
328
370
|
uploading, downloading, processing and deleting IO objects, backed by various
|
|
@@ -456,6 +498,7 @@ files:
|
|
|
456
498
|
- doc/release_notes/3.7.0.md
|
|
457
499
|
- doc/release_notes/3.7.1.md
|
|
458
500
|
- doc/release_notes/3.8.0.md
|
|
501
|
+
- doc/release_notes/3.9.0.md
|
|
459
502
|
- doc/retrieving_uploads.md
|
|
460
503
|
- doc/securing_uploads.md
|
|
461
504
|
- doc/storage/file_system.md
|
|
@@ -549,7 +592,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
549
592
|
- !ruby/object:Gem::Version
|
|
550
593
|
version: '0'
|
|
551
594
|
requirements: []
|
|
552
|
-
rubygems_version: 4.0.
|
|
595
|
+
rubygems_version: 4.0.13
|
|
553
596
|
specification_version: 4
|
|
554
597
|
summary: Toolkit for file attachments in Ruby applications
|
|
555
598
|
test_files: []
|