shrine 3.2.2 → 3.3.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 +36 -0
- data/README.md +5 -0
- data/doc/advantages.md +1 -1
- data/doc/carrierwave.md +7 -4
- data/doc/direct_s3.md +1 -0
- data/doc/external/articles.md +6 -8
- data/doc/external/extensions.md +3 -0
- data/doc/external/misc.md +23 -8
- data/doc/getting_started.md +4 -8
- data/doc/multiple_files.md +1 -1
- data/doc/paperclip.md +14 -5
- data/doc/plugins/add_metadata.md +20 -0
- data/doc/plugins/atomic_helpers.md +41 -3
- data/doc/plugins/derivatives.md +43 -12
- data/doc/plugins/download_endpoint.md +5 -5
- data/doc/plugins/dynamic_storage.md +1 -1
- data/doc/plugins/infer_extension.md +9 -0
- data/doc/plugins/metadata_attributes.md +1 -0
- data/doc/plugins/mirroring.md +1 -1
- data/doc/plugins/persistence.md +10 -1
- data/doc/plugins/store_dimensions.md +10 -0
- data/doc/plugins/url_options.md +2 -2
- data/doc/processing.md +7 -8
- data/doc/release_notes/2.8.0.md +1 -1
- data/doc/release_notes/3.2.1.md +2 -3
- data/doc/release_notes/3.3.0.md +105 -0
- data/doc/storage/s3.md +9 -5
- data/doc/upgrading_to_3.md +11 -27
- data/lib/shrine/attacher.rb +6 -1
- data/lib/shrine/plugins/activerecord.rb +1 -1
- data/lib/shrine/plugins/add_metadata.rb +6 -4
- data/lib/shrine/plugins/backgrounding.rb +2 -2
- data/lib/shrine/plugins/derivation_endpoint.rb +2 -1
- data/lib/shrine/plugins/derivatives.rb +45 -15
- data/lib/shrine/plugins/mirroring.rb +8 -8
- data/lib/shrine/plugins/presign_endpoint.rb +14 -2
- data/lib/shrine/plugins/remove_attachment.rb +5 -0
- data/lib/shrine/plugins/sequel.rb +1 -1
- data/lib/shrine/plugins/store_dimensions.rb +4 -2
- data/lib/shrine/plugins/upload_endpoint.rb +7 -2
- data/lib/shrine/storage/memory.rb +5 -3
- data/lib/shrine/storage/s3.rb +61 -6
- data/lib/shrine/version.rb +2 -2
- data/shrine.gemspec +1 -1
- metadata +6 -5
@@ -392,6 +392,7 @@ class Shrine
|
|
392
392
|
options[:type] = request.params["type"] if request.params["type"]
|
393
393
|
options[:disposition] = request.params["disposition"] if request.params["disposition"]
|
394
394
|
options[:filename] = request.params["filename"] if request.params["filename"]
|
395
|
+
options[:version] = request.params["version"] if request.params["version"]
|
395
396
|
options[:expires_in] = expires_in(request) if request.params["expires_at"]
|
396
397
|
|
397
398
|
derivation = uploaded_file.derivation(name, *args, **options)
|
@@ -738,7 +739,7 @@ class Shrine
|
|
738
739
|
def verify_signature(string, signature)
|
739
740
|
if signature.nil?
|
740
741
|
fail InvalidSignature, "missing \"signature\" param"
|
741
|
-
elsif signature
|
742
|
+
elsif !Rack::Utils.secure_compare(signature, generate_signature(string))
|
742
743
|
fail InvalidSignature, "provided signature does not match the calculated signature"
|
743
744
|
end
|
744
745
|
end
|
@@ -4,8 +4,6 @@ class Shrine
|
|
4
4
|
module Plugins
|
5
5
|
# Documentation can be found on https://shrinerb.com/docs/plugins/derivatives
|
6
6
|
module Derivatives
|
7
|
-
NOOP_PROCESSOR = -> (*) { Hash.new }
|
8
|
-
|
9
7
|
LOG_SUBSCRIBER = -> (event) do
|
10
8
|
Shrine.logger.info "Derivatives (#{event.duration}ms) – #{{
|
11
9
|
processor: event[:processor],
|
@@ -21,7 +19,7 @@ class Shrine
|
|
21
19
|
end
|
22
20
|
|
23
21
|
def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, **opts)
|
24
|
-
uploader.opts[:derivatives] ||= { processors: {}, storage: proc { store_key } }
|
22
|
+
uploader.opts[:derivatives] ||= { processors: {}, processor_settings: {}, storage: proc { store_key } }
|
25
23
|
uploader.opts[:derivatives].merge!(opts)
|
26
24
|
|
27
25
|
# instrumentation plugin integration
|
@@ -52,20 +50,37 @@ class Shrine
|
|
52
50
|
# Attacher.derivatives_processor :thumbnails do |original|
|
53
51
|
# # ...
|
54
52
|
# end
|
55
|
-
|
53
|
+
#
|
54
|
+
# By default, Shrine will convert the source IO object into a file
|
55
|
+
# before it's passed to the processor block. You can set `download:
|
56
|
+
# false` to pass the source IO object to the processor block as is.
|
57
|
+
#
|
58
|
+
# Attacher.derivatives_processor :thumbnails, download: false do |original|
|
59
|
+
# # ...
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# This can be useful if you'd like to defer or avoid a possibly
|
63
|
+
# expensive download operation for processor logic that does not
|
64
|
+
# require it.
|
65
|
+
def derivatives_processor(name = :default, download: true, &block)
|
56
66
|
if block
|
57
|
-
shrine_class.
|
67
|
+
shrine_class.derivatives_options[:processors][name.to_sym] = block
|
68
|
+
shrine_class.derivatives_options[:processor_settings][name.to_sym] = { download: download }
|
58
69
|
else
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
fail Error, "derivatives processor #{name.inspect} not registered" unless processor
|
63
|
-
|
64
|
-
processor
|
70
|
+
shrine_class.derivatives_options[:processors].fetch(name.to_sym) do
|
71
|
+
fail Error, "derivatives processor #{name.inspect} not registered" unless name == :default
|
72
|
+
end
|
65
73
|
end
|
66
74
|
end
|
67
75
|
alias derivatives derivatives_processor
|
68
76
|
|
77
|
+
# Returns settings for the given derivatives processor.
|
78
|
+
#
|
79
|
+
# Attacher.derivatives_processor_settings(:thumbnails) #=> { download: true }
|
80
|
+
def derivatives_processor_settings(name)
|
81
|
+
shrine_class.derivatives_options[:processor_settings][name.to_sym] || {}
|
82
|
+
end
|
83
|
+
|
69
84
|
# Specifies default storage to which derivatives will be uploaded.
|
70
85
|
#
|
71
86
|
# Attacher.derivatives_storage :other_store
|
@@ -79,9 +94,9 @@ class Shrine
|
|
79
94
|
# end
|
80
95
|
def derivatives_storage(storage_key = nil, &block)
|
81
96
|
if storage_key || block
|
82
|
-
shrine_class.
|
97
|
+
shrine_class.derivatives_options[:storage] = storage_key || block
|
83
98
|
else
|
84
|
-
shrine_class.
|
99
|
+
shrine_class.derivatives_options[:storage]
|
85
100
|
end
|
86
101
|
end
|
87
102
|
end
|
@@ -147,6 +162,7 @@ class Shrine
|
|
147
162
|
def promote(**options)
|
148
163
|
super
|
149
164
|
promote_derivatives
|
165
|
+
create_derivatives if create_derivatives_on_promote?
|
150
166
|
end
|
151
167
|
|
152
168
|
# Uploads any cached derivatives to permanent storage.
|
@@ -268,8 +284,10 @@ class Shrine
|
|
268
284
|
|
269
285
|
source ||= file!
|
270
286
|
|
271
|
-
|
272
|
-
|
287
|
+
processor_settings = self.class.derivatives_processor_settings(processor_name) || {}
|
288
|
+
|
289
|
+
if processor_settings[:download]
|
290
|
+
shrine_class.with_file(source) do |file|
|
273
291
|
_process_derivatives(processor_name, file, **options)
|
274
292
|
end
|
275
293
|
else
|
@@ -472,6 +490,8 @@ class Shrine
|
|
472
490
|
def _process_derivatives(processor_name, source, **options)
|
473
491
|
processor = self.class.derivatives_processor(processor_name)
|
474
492
|
|
493
|
+
return {} unless processor
|
494
|
+
|
475
495
|
result = instrument_derivatives(processor_name, source, options) do
|
476
496
|
instance_exec(source, **options, &processor)
|
477
497
|
end
|
@@ -521,6 +541,11 @@ class Shrine
|
|
521
541
|
o2
|
522
542
|
end
|
523
543
|
end
|
544
|
+
|
545
|
+
# Whether to automatically create derivatives on promotion
|
546
|
+
def create_derivatives_on_promote?
|
547
|
+
shrine_class.derivatives_options[:create_on_promote]
|
548
|
+
end
|
524
549
|
end
|
525
550
|
|
526
551
|
module ClassMethods
|
@@ -577,6 +602,11 @@ class Shrine
|
|
577
602
|
yield path, object
|
578
603
|
end
|
579
604
|
end
|
605
|
+
|
606
|
+
# Returns derivatives plugin options.
|
607
|
+
def derivatives_options
|
608
|
+
opts[:derivatives]
|
609
|
+
end
|
580
610
|
end
|
581
611
|
|
582
612
|
module FileMethods
|
@@ -53,7 +53,7 @@ class Shrine
|
|
53
53
|
# Mirrors upload to other mirror storages.
|
54
54
|
def upload(io, mirror: true, **options)
|
55
55
|
file = super(io, **options)
|
56
|
-
file.trigger_mirror_upload if mirror
|
56
|
+
file.trigger_mirror_upload(**options) if mirror
|
57
57
|
file
|
58
58
|
end
|
59
59
|
end
|
@@ -61,31 +61,31 @@ class Shrine
|
|
61
61
|
module FileMethods
|
62
62
|
# Mirrors upload if mirrors are defined. Calls mirror block if
|
63
63
|
# registered, otherwise mirrors synchronously.
|
64
|
-
def trigger_mirror_upload
|
64
|
+
def trigger_mirror_upload(**options)
|
65
65
|
return unless shrine_class.mirrors[storage_key] && shrine_class.mirror_upload?
|
66
66
|
|
67
67
|
if shrine_class.mirror_upload_block
|
68
|
-
mirror_upload_background
|
68
|
+
mirror_upload_background(**options)
|
69
69
|
else
|
70
|
-
mirror_upload
|
70
|
+
mirror_upload(**options)
|
71
71
|
end
|
72
72
|
end
|
73
73
|
|
74
74
|
# Calls mirror upload block.
|
75
|
-
def mirror_upload_background
|
75
|
+
def mirror_upload_background(**options)
|
76
76
|
fail Error, "mirror upload block is not registered" unless shrine_class.mirror_upload_block
|
77
77
|
|
78
|
-
shrine_class.mirror_upload_block.call(self)
|
78
|
+
shrine_class.mirror_upload_block.call(self, **options)
|
79
79
|
end
|
80
80
|
|
81
81
|
# Uploads the file to each mirror storage.
|
82
|
-
def mirror_upload
|
82
|
+
def mirror_upload(**options)
|
83
83
|
previously_opened = opened?
|
84
84
|
|
85
85
|
each_mirror do |mirror|
|
86
86
|
rewind if opened?
|
87
87
|
|
88
|
-
shrine_class.upload(self, mirror, location: id, close: false, action: :mirror)
|
88
|
+
shrine_class.upload(self, mirror, **options, location: id, close: false, action: :mirror)
|
89
89
|
end
|
90
90
|
ensure
|
91
91
|
if opened? && !previously_opened
|
@@ -81,9 +81,14 @@ class Shrine
|
|
81
81
|
|
82
82
|
status, headers, body = catch(:halt) do
|
83
83
|
error!(404, "Not Found") unless ["", "/"].include?(request.path_info)
|
84
|
-
error!(405, "Method Not Allowed") unless request.get?
|
85
84
|
|
86
|
-
|
85
|
+
if request.get?
|
86
|
+
handle_request(request)
|
87
|
+
elsif request.options?
|
88
|
+
handle_options_request(request)
|
89
|
+
else
|
90
|
+
error!(405, "Method Not Allowed")
|
91
|
+
end
|
87
92
|
end
|
88
93
|
|
89
94
|
headers["Content-Length"] ||= body.map(&:bytesize).inject(0, :+).to_s
|
@@ -109,6 +114,13 @@ class Shrine
|
|
109
114
|
make_response(presign, request)
|
110
115
|
end
|
111
116
|
|
117
|
+
# Uppy client sends an OPTIONS request to fetch information about the
|
118
|
+
# Uppy Companion. Since our Rack app is only acting as Uppy Companion, we
|
119
|
+
# just return a successful response.
|
120
|
+
def handle_options_request(request)
|
121
|
+
[200, {}, []]
|
122
|
+
end
|
123
|
+
|
112
124
|
# Generates the location using `Shrine#generate_uid`, and extracts the
|
113
125
|
# extension from the `filename` query parameter. If `:presign_location`
|
114
126
|
# option is given, calls that instead.
|
@@ -32,6 +32,11 @@ class Shrine
|
|
32
32
|
|
33
33
|
private
|
34
34
|
|
35
|
+
# Don't override previously removed attachment that wasn't yet deleted.
|
36
|
+
def change?(file)
|
37
|
+
super && !(changed? && remove?)
|
38
|
+
end
|
39
|
+
|
35
40
|
# Rails sends "0" or "false" if the checkbox hasn't been ticked.
|
36
41
|
def remove?
|
37
42
|
remove && remove != "" && remove !~ /\A(0|false)\z/
|
@@ -12,7 +12,7 @@ class Shrine
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, **opts)
|
15
|
-
uploader.opts[:store_dimensions] ||= { analyzer: :fastimage, on_error: :warn }
|
15
|
+
uploader.opts[:store_dimensions] ||= { analyzer: :fastimage, on_error: :warn, auto_extraction: true }
|
16
16
|
uploader.opts[:store_dimensions].merge!(opts)
|
17
17
|
|
18
18
|
# resolve error strategy
|
@@ -71,8 +71,10 @@ class Shrine
|
|
71
71
|
end
|
72
72
|
|
73
73
|
module InstanceMethods
|
74
|
-
# We update the metadata with "width" and "height".
|
75
74
|
def extract_metadata(io, **options)
|
75
|
+
return super unless opts[:store_dimensions][:auto_extraction]
|
76
|
+
|
77
|
+
# We update the metadata with "width" and "height".
|
76
78
|
width, height = self.class.extract_dimensions(io)
|
77
79
|
|
78
80
|
super.merge!("width" => width, "height" => height)
|
@@ -135,9 +135,14 @@ class Shrine
|
|
135
135
|
end
|
136
136
|
|
137
137
|
error!(400, "Upload Not Found") if value.nil?
|
138
|
-
error!(400, "Upload Not Valid") unless value.is_a?(Hash) && value[:tempfile]
|
139
138
|
|
140
|
-
|
139
|
+
if value.is_a?(Hash) && value[:tempfile]
|
140
|
+
@shrine_class.rack_file(value)
|
141
|
+
elsif %i[read rewind eof? close].all? { |m| value.respond_to?(m) }
|
142
|
+
value
|
143
|
+
else
|
144
|
+
error!(400, "Upload Not Valid")
|
145
|
+
end
|
141
146
|
end
|
142
147
|
|
143
148
|
# Returns a hash of information containing `:action` and `:request`
|
@@ -12,12 +12,14 @@ class Shrine
|
|
12
12
|
@store = store
|
13
13
|
end
|
14
14
|
|
15
|
-
def upload(io, id,
|
15
|
+
def upload(io, id, **)
|
16
16
|
store[id] = io.read
|
17
17
|
end
|
18
18
|
|
19
|
-
def open(id,
|
20
|
-
StringIO.new(store.fetch(id))
|
19
|
+
def open(id, **)
|
20
|
+
io = StringIO.new(store.fetch(id))
|
21
|
+
io.set_encoding(io.string.encoding) # Ruby 2.7.0 – https://bugs.ruby-lang.org/issues/16497
|
22
|
+
io
|
21
23
|
rescue KeyError
|
22
24
|
raise Shrine::FileNotFound, "file #{id.inspect} not found on storage"
|
23
25
|
end
|
data/lib/shrine/storage/s3.rb
CHANGED
@@ -9,6 +9,7 @@ require "down/chunked_io"
|
|
9
9
|
require "content_disposition"
|
10
10
|
|
11
11
|
require "uri"
|
12
|
+
require "tempfile"
|
12
13
|
|
13
14
|
class Shrine
|
14
15
|
module Storage
|
@@ -103,7 +104,7 @@ class Shrine
|
|
103
104
|
#
|
104
105
|
# [`Aws::S3::Object#get`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#get-instance_method
|
105
106
|
def open(id, rewindable: true, **options)
|
106
|
-
chunks, length = get(
|
107
|
+
chunks, length = get(id, **options)
|
107
108
|
|
108
109
|
Down::ChunkedIO.new(chunks: chunks, rewindable: rewindable, size: length)
|
109
110
|
rescue Aws::S3::Errors::NoSuchKey
|
@@ -212,7 +213,7 @@ class Shrine
|
|
212
213
|
|
213
214
|
# Returns an `Aws::S3::Object` for the given id.
|
214
215
|
def object(id)
|
215
|
-
bucket.object(
|
216
|
+
bucket.object(object_key(id))
|
216
217
|
end
|
217
218
|
|
218
219
|
private
|
@@ -279,9 +280,12 @@ class Shrine
|
|
279
280
|
end
|
280
281
|
end
|
281
282
|
|
283
|
+
# Aws::S3::Object#get doesn't allow us to get the content length of the
|
284
|
+
# object before all content is downloaded, so we hack our way around it.
|
285
|
+
# This way get the content length without an additional HEAD request.
|
282
286
|
if Gem::Version.new(Aws::CORE_GEM_VERSION) >= Gem::Version.new("3.104.0")
|
283
|
-
def get(
|
284
|
-
enum = object.enum_for(:get, **params)
|
287
|
+
def get(id, **params)
|
288
|
+
enum = object(id).enum_for(:get, **params)
|
285
289
|
|
286
290
|
begin
|
287
291
|
content_length = Integer(enum.peek.last["content-length"])
|
@@ -294,8 +298,8 @@ class Shrine
|
|
294
298
|
[chunks, content_length]
|
295
299
|
end
|
296
300
|
else
|
297
|
-
def get(
|
298
|
-
req = client.build_request(:get_object, bucket: bucket.name, key:
|
301
|
+
def get(id, **params)
|
302
|
+
req = client.build_request(:get_object, bucket: bucket.name, key: object_key(id), **params)
|
299
303
|
|
300
304
|
body = req.enum_for(:send_request)
|
301
305
|
begin
|
@@ -326,6 +330,57 @@ class Shrine
|
|
326
330
|
bucket.delete_objects(delete: delete_params)
|
327
331
|
end
|
328
332
|
end
|
333
|
+
|
334
|
+
# Returns object key with potential prefix.
|
335
|
+
def object_key(id)
|
336
|
+
[*prefix, id].join("/")
|
337
|
+
end
|
338
|
+
|
339
|
+
# Adds support for Aws::S3::Encryption::Client.
|
340
|
+
module ClientSideEncryption
|
341
|
+
attr_reader :encryption_client
|
342
|
+
|
343
|
+
# Save the encryption client and continue initialization with normal
|
344
|
+
# client.
|
345
|
+
def initialize(client: nil, **options)
|
346
|
+
return super unless client.class.name.start_with?("Aws::S3::Encryption")
|
347
|
+
|
348
|
+
super(client: client.client, **options)
|
349
|
+
@encryption_client = client
|
350
|
+
end
|
351
|
+
|
352
|
+
private
|
353
|
+
|
354
|
+
# Encryption client doesn't support multipart uploads, so we always use
|
355
|
+
# #put_object.
|
356
|
+
def put(io, id, **options)
|
357
|
+
return super unless encryption_client
|
358
|
+
|
359
|
+
encryption_client.put_object(body: io, bucket: bucket.name, key: object_key(id), **options)
|
360
|
+
end
|
361
|
+
|
362
|
+
def get(id, **options)
|
363
|
+
return super unless encryption_client
|
364
|
+
|
365
|
+
# Encryption client v2 warns against streaming download, so we first
|
366
|
+
# download all content into a file.
|
367
|
+
tempfile = Tempfile.new("shrine-s3", binmode: true)
|
368
|
+
response = encryption_client.get_object(response_target: tempfile, bucket: bucket.name, key: object_key(id), **options)
|
369
|
+
tempfile.rewind
|
370
|
+
|
371
|
+
chunks = Enumerator.new do |yielder|
|
372
|
+
begin
|
373
|
+
yielder << tempfile.read(16*1024) until tempfile.eof?
|
374
|
+
ensure
|
375
|
+
tempfile.close!
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
[chunks, tempfile.size]
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
prepend ClientSideEncryption
|
329
384
|
end
|
330
385
|
end
|
331
386
|
end
|
data/lib/shrine/version.rb
CHANGED
data/shrine.gemspec
CHANGED
@@ -61,7 +61,7 @@ direct uploads for fully asynchronous user experience.
|
|
61
61
|
gem.add_development_dependency "ruby-vips", "~> 2.0" unless ENV["CI"]
|
62
62
|
|
63
63
|
# for S3 storage
|
64
|
-
gem.add_development_dependency "aws-sdk-s3", "~> 1.
|
64
|
+
gem.add_development_dependency "aws-sdk-s3", "~> 1.69"
|
65
65
|
gem.add_development_dependency "aws-sdk-core", "~> 3.23"
|
66
66
|
|
67
67
|
# for instrumentation plugin
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shrine
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Janko Marohnić
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-10-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: down
|
@@ -240,14 +240,14 @@ dependencies:
|
|
240
240
|
requirements:
|
241
241
|
- - "~>"
|
242
242
|
- !ruby/object:Gem::Version
|
243
|
-
version: '1.
|
243
|
+
version: '1.69'
|
244
244
|
type: :development
|
245
245
|
prerelease: false
|
246
246
|
version_requirements: !ruby/object:Gem::Requirement
|
247
247
|
requirements:
|
248
248
|
- - "~>"
|
249
249
|
- !ruby/object:Gem::Version
|
250
|
-
version: '1.
|
250
|
+
version: '1.69'
|
251
251
|
- !ruby/object:Gem::Dependency
|
252
252
|
name: aws-sdk-core
|
253
253
|
requirement: !ruby/object:Gem::Requirement
|
@@ -458,6 +458,7 @@ files:
|
|
458
458
|
- doc/release_notes/3.2.0.md
|
459
459
|
- doc/release_notes/3.2.1.md
|
460
460
|
- doc/release_notes/3.2.2.md
|
461
|
+
- doc/release_notes/3.3.0.md
|
461
462
|
- doc/retrieving_uploads.md
|
462
463
|
- doc/securing_uploads.md
|
463
464
|
- doc/storage/file_system.md
|
@@ -552,7 +553,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
552
553
|
- !ruby/object:Gem::Version
|
553
554
|
version: '0'
|
554
555
|
requirements: []
|
555
|
-
rubygems_version: 3.1.
|
556
|
+
rubygems_version: 3.1.4
|
556
557
|
signing_key:
|
557
558
|
specification_version: 4
|
558
559
|
summary: Toolkit for file attachments in Ruby applications
|