activestorage 7.0.0.alpha2 → 7.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of activestorage might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b4ee316f0d82476667278f6a01db29789d34942e1d0bf731fe747931e026289
4
- data.tar.gz: e29835ba8739a80e1c6a07ef765cad05cb140fd63d29d79a176788cca8a0180f
3
+ metadata.gz: f08091492d077c28be3b94de7fb3e4f02b6cd95c8c1d9bb987d620e2839e1208
4
+ data.tar.gz: 4fe88a44de6de535c2167bf10917b08111eb77ea4afc40cbce753f19482d9292
5
5
  SHA512:
6
- metadata.gz: 27e7c48eb5badb4f6676a97bbc4b40d6041d9195de3619a4ffdbfeb149c73946b8beeeadf4782aa41be0fe675d015172fde15220f4dd2866535b38163501a50a
7
- data.tar.gz: 66a220e052991ed37729c9c40b49769178e342fa5e0e89640ecc8f5640502f6667742f4ace105fb827a3ea67a0958bbf65433030e4a750adaef119f5962fe285
6
+ metadata.gz: e5558429f5f6e1d268ca1adc7cf553bbd1cb211aa6f3cb3a5d306c14b55256e95d88f012386a6e1597c2a79a9679687c2ef86bd6caf1bf3b8bf95dd8f85b25c3
7
+ data.tar.gz: ec71d2715da6cc32f7f40f231c6e482f5d3984bb97d90bcb3634e65589e0430b5383452c7a71ddb551b0ee652116168b49c3a38bd3bdc3d8b87620aa408f6910
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ * `Add ActiveStorage::Blob.compose` to concatenate multiple blobs.
2
+
3
+ *Gannon McGibbon*
4
+
5
+ * Setting custom metadata on blobs are now persisted to remote storage.
6
+
7
+ *joshuamsager*
8
+
9
+ * Support direct uploads to multiple services.
10
+
11
+ *Dmitry Tsepelev*
12
+
13
+ * Invalid default content types are deprecated
14
+
15
+ Blobs created with content_type `image/jpg`, `image/pjpeg`, `image/bmp`, `text/javascript` will now produce
16
+ a deprecation warning, since these are not valid content types.
17
+
18
+ These content types will be removed from the defaults in Rails 7.1.
19
+
20
+ You can set `config.active_storage.silence_invalid_content_types_warning = true` to dismiss the warning.
21
+
22
+ *Alex Ghiculescu*
23
+
1
24
  ## Rails 7.0.0.alpha2 (September 15, 2021) ##
2
25
 
3
26
  * No changes.
@@ -508,7 +508,7 @@ function toArray(value) {
508
508
  }
509
509
 
510
510
  class BlobRecord {
511
- constructor(file, checksum, url) {
511
+ constructor(file, checksum, url, directUploadToken, attachmentName) {
512
512
  this.file = file;
513
513
  this.attributes = {
514
514
  filename: file.name,
@@ -516,6 +516,8 @@ class BlobRecord {
516
516
  byte_size: file.size,
517
517
  checksum: checksum
518
518
  };
519
+ this.directUploadToken = directUploadToken;
520
+ this.attachmentName = attachmentName;
519
521
  this.xhr = new XMLHttpRequest;
520
522
  this.xhr.open("POST", url, true);
521
523
  this.xhr.responseType = "json";
@@ -543,7 +545,9 @@ class BlobRecord {
543
545
  create(callback) {
544
546
  this.callback = callback;
545
547
  this.xhr.send(JSON.stringify({
546
- blob: this.attributes
548
+ blob: this.attributes,
549
+ direct_upload_token: this.directUploadToken,
550
+ attachment_name: this.attachmentName
547
551
  }));
548
552
  }
549
553
  requestDidLoad(event) {
@@ -604,10 +608,12 @@ class BlobUpload {
604
608
  let id = 0;
605
609
 
606
610
  class DirectUpload {
607
- constructor(file, url, delegate) {
611
+ constructor(file, url, serviceName, attachmentName, delegate) {
608
612
  this.id = ++id;
609
613
  this.file = file;
610
614
  this.url = url;
615
+ this.serviceName = serviceName;
616
+ this.attachmentName = attachmentName;
611
617
  this.delegate = delegate;
612
618
  }
613
619
  create(callback) {
@@ -616,7 +622,7 @@ class DirectUpload {
616
622
  callback(error);
617
623
  return;
618
624
  }
619
- const blob = new BlobRecord(this.file, checksum, this.url);
625
+ const blob = new BlobRecord(this.file, checksum, this.url, this.serviceName, this.attachmentName);
620
626
  notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
621
627
  blob.create((error => {
622
628
  if (error) {
@@ -647,7 +653,7 @@ class DirectUploadController {
647
653
  constructor(input, file) {
648
654
  this.input = input;
649
655
  this.file = file;
650
- this.directUpload = new DirectUpload(this.file, this.url, this);
656
+ this.directUpload = new DirectUpload(this.file, this.url, this.directUploadToken, this.attachmentName, this);
651
657
  this.dispatch("initialize");
652
658
  }
653
659
  start(callback) {
@@ -678,6 +684,12 @@ class DirectUploadController {
678
684
  get url() {
679
685
  return this.input.getAttribute("data-direct-upload-url");
680
686
  }
687
+ get directUploadToken() {
688
+ return this.input.getAttribute("data-direct-upload-token");
689
+ }
690
+ get attachmentName() {
691
+ return this.input.getAttribute("data-direct-upload-attachment-name");
692
+ }
681
693
  dispatch(name, detail = {}) {
682
694
  detail.file = this.file;
683
695
  detail.id = this.directUpload.id;
@@ -503,7 +503,7 @@
503
503
  }
504
504
  }
505
505
  class BlobRecord {
506
- constructor(file, checksum, url) {
506
+ constructor(file, checksum, url, directUploadToken, attachmentName) {
507
507
  this.file = file;
508
508
  this.attributes = {
509
509
  filename: file.name,
@@ -511,6 +511,8 @@
511
511
  byte_size: file.size,
512
512
  checksum: checksum
513
513
  };
514
+ this.directUploadToken = directUploadToken;
515
+ this.attachmentName = attachmentName;
514
516
  this.xhr = new XMLHttpRequest;
515
517
  this.xhr.open("POST", url, true);
516
518
  this.xhr.responseType = "json";
@@ -538,7 +540,9 @@
538
540
  create(callback) {
539
541
  this.callback = callback;
540
542
  this.xhr.send(JSON.stringify({
541
- blob: this.attributes
543
+ blob: this.attributes,
544
+ direct_upload_token: this.directUploadToken,
545
+ attachment_name: this.attachmentName
542
546
  }));
543
547
  }
544
548
  requestDidLoad(event) {
@@ -596,10 +600,12 @@
596
600
  }
597
601
  let id = 0;
598
602
  class DirectUpload {
599
- constructor(file, url, delegate) {
603
+ constructor(file, url, serviceName, attachmentName, delegate) {
600
604
  this.id = ++id;
601
605
  this.file = file;
602
606
  this.url = url;
607
+ this.serviceName = serviceName;
608
+ this.attachmentName = attachmentName;
603
609
  this.delegate = delegate;
604
610
  }
605
611
  create(callback) {
@@ -608,7 +614,7 @@
608
614
  callback(error);
609
615
  return;
610
616
  }
611
- const blob = new BlobRecord(this.file, checksum, this.url);
617
+ const blob = new BlobRecord(this.file, checksum, this.url, this.serviceName, this.attachmentName);
612
618
  notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
613
619
  blob.create((error => {
614
620
  if (error) {
@@ -637,7 +643,7 @@
637
643
  constructor(input, file) {
638
644
  this.input = input;
639
645
  this.file = file;
640
- this.directUpload = new DirectUpload(this.file, this.url, this);
646
+ this.directUpload = new DirectUpload(this.file, this.url, this.directUploadToken, this.attachmentName, this);
641
647
  this.dispatch("initialize");
642
648
  }
643
649
  start(callback) {
@@ -668,6 +674,12 @@
668
674
  get url() {
669
675
  return this.input.getAttribute("data-direct-upload-url");
670
676
  }
677
+ get directUploadToken() {
678
+ return this.input.getAttribute("data-direct-upload-token");
679
+ }
680
+ get attachmentName() {
681
+ return this.input.getAttribute("data-direct-upload-attachment-name");
682
+ }
671
683
  dispatch(name, detail = {}) {
672
684
  detail.file = this.file;
673
685
  detail.id = this.directUpload.id;
@@ -4,8 +4,10 @@
4
4
  # When the client-side upload is completed, the signed_blob_id can be submitted as part of the form to reference
5
5
  # the blob that was created up front.
6
6
  class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
7
+ include ActiveStorage::DirectUploadToken
8
+
7
9
  def create
8
- blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
10
+ blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args.merge(service_name: verified_service_name))
9
11
  render json: direct_upload_json(blob)
10
12
  end
11
13
 
@@ -14,6 +16,10 @@ class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
14
16
  params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys
15
17
  end
16
18
 
19
+ def verified_service_name
20
+ ActiveStorage::DirectUploadToken.verify_direct_upload_token(params[:direct_upload_token], params[:attachment_name], session)
21
+ end
22
+
17
23
  def direct_upload_json(blob)
18
24
  blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
19
25
  url: blob.service_url_for_direct_upload,
@@ -23,6 +23,7 @@ class ActiveStorage::DiskController < ActiveStorage::BaseController
23
23
  if token = decode_verified_token
24
24
  if acceptable_content?(token)
25
25
  named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
26
+ head :no_content
26
27
  else
27
28
  head :unprocessable_entity
28
29
  end
@@ -1,16 +1,19 @@
1
1
  import { getMetaValue } from "./helpers"
2
2
 
3
3
  export class BlobRecord {
4
- constructor(file, checksum, url) {
4
+ constructor(file, checksum, url, directUploadToken, attachmentName) {
5
5
  this.file = file
6
6
 
7
7
  this.attributes = {
8
8
  filename: file.name,
9
9
  content_type: file.type || "application/octet-stream",
10
10
  byte_size: file.size,
11
- checksum: checksum
11
+ checksum: checksum,
12
12
  }
13
13
 
14
+ this.directUploadToken = directUploadToken
15
+ this.attachmentName = attachmentName
16
+
14
17
  this.xhr = new XMLHttpRequest
15
18
  this.xhr.open("POST", url, true)
16
19
  this.xhr.responseType = "json"
@@ -43,7 +46,11 @@ export class BlobRecord {
43
46
 
44
47
  create(callback) {
45
48
  this.callback = callback
46
- this.xhr.send(JSON.stringify({ blob: this.attributes }))
49
+ this.xhr.send(JSON.stringify({
50
+ blob: this.attributes,
51
+ direct_upload_token: this.directUploadToken,
52
+ attachment_name: this.attachmentName
53
+ }))
47
54
  }
48
55
 
49
56
  requestDidLoad(event) {
@@ -5,10 +5,12 @@ import { BlobUpload } from "./blob_upload"
5
5
  let id = 0
6
6
 
7
7
  export class DirectUpload {
8
- constructor(file, url, delegate) {
8
+ constructor(file, url, serviceName, attachmentName, delegate) {
9
9
  this.id = ++id
10
10
  this.file = file
11
11
  this.url = url
12
+ this.serviceName = serviceName
13
+ this.attachmentName = attachmentName
12
14
  this.delegate = delegate
13
15
  }
14
16
 
@@ -19,7 +21,7 @@ export class DirectUpload {
19
21
  return
20
22
  }
21
23
 
22
- const blob = new BlobRecord(this.file, checksum, this.url)
24
+ const blob = new BlobRecord(this.file, checksum, this.url, this.serviceName, this.attachmentName)
23
25
  notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
24
26
 
25
27
  blob.create(error => {
@@ -5,7 +5,7 @@ export class DirectUploadController {
5
5
  constructor(input, file) {
6
6
  this.input = input
7
7
  this.file = file
8
- this.directUpload = new DirectUpload(this.file, this.url, this)
8
+ this.directUpload = new DirectUpload(this.file, this.url, this.directUploadToken, this.attachmentName, this)
9
9
  this.dispatch("initialize")
10
10
  }
11
11
 
@@ -41,6 +41,14 @@ export class DirectUploadController {
41
41
  return this.input.getAttribute("data-direct-upload-url")
42
42
  }
43
43
 
44
+ get directUploadToken() {
45
+ return this.input.getAttribute("data-direct-upload-token")
46
+ }
47
+
48
+ get attachmentName() {
49
+ return this.input.getAttribute("data-direct-upload-attachment-name")
50
+ }
51
+
44
52
  dispatch(name, detail = {}) {
45
53
  detail.file = this.file
46
54
  detail.id = this.directUpload.id
@@ -39,7 +39,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
39
39
  MINIMUM_TOKEN_LENGTH = 28
40
40
 
41
41
  has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
42
- store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
42
+ store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON
43
43
 
44
44
  class_attribute :services, default: {}
45
45
  class_attribute :service, instance_accessor: false
@@ -52,13 +52,14 @@ class ActiveStorage::Blob < ActiveStorage::Record
52
52
  self.service_name ||= self.class.service&.name
53
53
  end
54
54
 
55
- after_update_commit :update_service_metadata, if: :content_type_previously_changed?
55
+ after_update_commit :update_service_metadata, if: -> { content_type_previously_changed? || metadata_previously_changed? }
56
56
 
57
57
  before_destroy(prepend: true) do
58
58
  raise ActiveRecord::InvalidForeignKey if attachments.exists?
59
59
  end
60
60
 
61
61
  validates :service_name, presence: true
62
+ validates :checksum, presence: true, unless: :composed
62
63
 
63
64
  validate do
64
65
  if service_name_changed? && service_name.present?
@@ -145,6 +146,18 @@ class ActiveStorage::Blob < ActiveStorage::Record
145
146
  all
146
147
  end
147
148
  end
149
+
150
+ # Concatenate multiple blobs into a single "composed" blob.
151
+ def compose(blobs, filename:, content_type: nil, metadata: nil)
152
+ raise ActiveRecord::RecordNotSaved, "All blobs must be persisted." if blobs.any?(&:new_record?)
153
+
154
+ content_type ||= blobs.pluck(:content_type).compact.first
155
+
156
+ new(filename: filename, content_type: content_type, metadata: metadata, byte_size: blobs.sum(&:byte_size)).tap do |combined_blob|
157
+ combined_blob.compose(blobs.pluck(:key))
158
+ combined_blob.save!
159
+ end
160
+ end
148
161
  end
149
162
 
150
163
  # Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
@@ -168,6 +181,14 @@ class ActiveStorage::Blob < ActiveStorage::Record
168
181
  ActiveStorage::Filename.new(self[:filename])
169
182
  end
170
183
 
184
+ def custom_metadata
185
+ self[:metadata][:custom] || {}
186
+ end
187
+
188
+ def custom_metadata=(metadata)
189
+ self[:metadata] = self[:metadata].merge(custom: metadata)
190
+ end
191
+
171
192
  # Returns true if the content_type of this blob is in the image range, like image/png.
172
193
  def image?
173
194
  content_type.start_with?("image")
@@ -200,12 +221,12 @@ class ActiveStorage::Blob < ActiveStorage::Record
200
221
  # Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
201
222
  # short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
202
223
  def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
203
- service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
224
+ service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata
204
225
  end
205
226
 
206
227
  # Returns a Hash of headers for +service_url_for_direct_upload+ requests.
207
228
  def service_headers_for_direct_upload
208
- service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
229
+ service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata
209
230
  end
210
231
 
211
232
  def content_type_for_serving # :nodoc:
@@ -247,6 +268,11 @@ class ActiveStorage::Blob < ActiveStorage::Record
247
268
  service.upload key, io, checksum: checksum, **service_metadata
248
269
  end
249
270
 
271
+ def compose(keys) # :nodoc:
272
+ self.composed = true
273
+ service.compose(keys, key, **service_metadata)
274
+ end
275
+
250
276
  # Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned.
251
277
  # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
252
278
  def download(&block)
@@ -272,8 +298,14 @@ class ActiveStorage::Blob < ActiveStorage::Record
272
298
  #
273
299
  # Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum.
274
300
  def open(tmpdir: nil, &block)
275
- service.open key, checksum: checksum,
276
- name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ], tmpdir: tmpdir, &block
301
+ service.open(
302
+ key,
303
+ checksum: checksum,
304
+ verify: !composed,
305
+ name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ],
306
+ tmpdir: tmpdir,
307
+ &block
308
+ )
277
309
  end
278
310
 
279
311
  def mirror_later # :nodoc:
@@ -308,6 +340,31 @@ class ActiveStorage::Blob < ActiveStorage::Record
308
340
  services.fetch(service_name)
309
341
  end
310
342
 
343
+ def content_type=(value)
344
+ unless ActiveStorage.silence_invalid_content_types_warning
345
+ if INVALID_VARIABLE_CONTENT_TYPES_DEPRECATED_IN_RAILS_7.include?(value)
346
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
347
+ #{value} is not a valid content type, it should not be used when creating a blob, and support for it will be removed in Rails 7.1.
348
+ If you want to keep supporting this content type past Rails 7.1, add it to `config.active_storage.variable_content_types`.
349
+ Dismiss this warning by setting `config.active_storage.silence_invalid_content_types_warning = true`.
350
+ MSG
351
+ end
352
+
353
+ if INVALID_VARIABLE_CONTENT_TYPES_TO_SERVE_AS_BINARY_DEPRECATED_IN_RAILS_7.include?(value)
354
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
355
+ #{value} is not a valid content type, it should not be used when creating a blob, and support for it will be removed in Rails 7.1.
356
+ If you want to keep supporting this content type past Rails 7.1, add it to `config.active_storage.content_types_to_serve_as_binary`.
357
+ Dismiss this warning by setting `config.active_storage.silence_invalid_content_types_warning = true`.
358
+ MSG
359
+ end
360
+ end
361
+
362
+ super
363
+ end
364
+
365
+ INVALID_VARIABLE_CONTENT_TYPES_DEPRECATED_IN_RAILS_7 = ["image/jpg", "image/pjpeg", "image/bmp"]
366
+ INVALID_VARIABLE_CONTENT_TYPES_TO_SERVE_AS_BINARY_DEPRECATED_IN_RAILS_7 = ["text/javascript"]
367
+
311
368
  private
312
369
  def compute_checksum_in_chunks(io)
313
370
  OpenSSL::Digest::MD5.new.tap do |checksum|
@@ -337,11 +394,11 @@ class ActiveStorage::Blob < ActiveStorage::Record
337
394
 
338
395
  def service_metadata
339
396
  if forcibly_serve_as_binary?
340
- { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
397
+ { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename, custom_metadata: custom_metadata }
341
398
  elsif !allowed_inline?
342
- { content_type: content_type, disposition: :attachment, filename: filename }
399
+ { content_type: content_type, disposition: :attachment, filename: filename, custom_metadata: custom_metadata }
343
400
  else
344
- { content_type: content_type }
401
+ { content_type: content_type, custom_metadata: custom_metadata }
345
402
  end
346
403
  end
347
404
 
@@ -10,7 +10,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
10
10
  t.text :metadata
11
11
  t.string :service_name, null: false
12
12
  t.bigint :byte_size, null: false
13
- t.string :checksum, null: false
13
+ t.string :checksum
14
14
 
15
15
  if connection.supports_datetime_with_precision?
16
16
  t.datetime :created_at, precision: 6, null: false
@@ -32,7 +32,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
32
32
  t.datetime :created_at, null: false
33
33
  end
34
34
 
35
- t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
35
+ t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
36
36
  t.foreign_key :active_storage_blobs, column: :blob_id
37
37
  end
38
38
 
@@ -40,7 +40,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
40
40
  t.belongs_to :blob, null: false, index: false, type: foreign_key_type
41
41
  t.string :variation_digest, null: false
42
42
 
43
- t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
43
+ t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
44
44
  t.foreign_key :active_storage_blobs, column: :blob_id
45
45
  end
46
46
  end
@@ -0,0 +1,5 @@
1
+ class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0]
2
+ def change
3
+ change_column_null(:active_storage_blobs, :checksum, true)
4
+ end
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module DirectUploadToken
5
+ extend self
6
+
7
+ SEPARATOR = "."
8
+ DIRECT_UPLOAD_TOKEN_LENGTH = 32
9
+
10
+ def generate_direct_upload_token(attachment_name, service_name, session)
11
+ token = direct_upload_token(session, attachment_name)
12
+ encode_direct_upload_token([service_name, token].join(SEPARATOR))
13
+ end
14
+
15
+ def verify_direct_upload_token(token, attachment_name, session)
16
+ raise ActiveStorage::InvalidDirectUploadTokenError if token.nil?
17
+
18
+ service_name, *token_components = decode_token(token).split(SEPARATOR)
19
+ decoded_token = token_components.join(SEPARATOR)
20
+
21
+ return service_name if valid_direct_upload_token?(decoded_token, attachment_name, session)
22
+
23
+ raise ActiveStorage::InvalidDirectUploadTokenError
24
+ end
25
+
26
+ private
27
+ def direct_upload_token(session, attachment_name) # :doc:
28
+ direct_upload_token_hmac(session, "direct_upload##{attachment_name}")
29
+ end
30
+
31
+ def valid_direct_upload_token?(token, attachment_name, session) # :doc:
32
+ correct_token = direct_upload_token(session, attachment_name)
33
+ ActiveSupport::SecurityUtils.fixed_length_secure_compare(token, correct_token)
34
+ rescue ArgumentError
35
+ raise ActiveStorage::InvalidDirectUploadTokenError
36
+ end
37
+
38
+ def direct_upload_token_hmac(session, identifier) # :doc:
39
+ OpenSSL::HMAC.digest(
40
+ OpenSSL::Digest::SHA256.new,
41
+ real_direct_upload_token(session),
42
+ identifier
43
+ )
44
+ end
45
+
46
+ def real_direct_upload_token(session) # :doc:
47
+ session[:_direct_upload_token] ||= SecureRandom.urlsafe_base64(DIRECT_UPLOAD_TOKEN_LENGTH, padding: false)
48
+ encode_direct_upload_token(session[:_direct_upload_token])
49
+ end
50
+
51
+ def decode_token(encoded_token) # :nodoc:
52
+ Base64.urlsafe_decode64(encoded_token)
53
+ end
54
+
55
+ def encode_direct_upload_token(raw_token) # :nodoc:
56
+ Base64.urlsafe_encode64(raw_token)
57
+ end
58
+ end
59
+ end
@@ -8,10 +8,10 @@ module ActiveStorage
8
8
  @service = service
9
9
  end
10
10
 
11
- def open(key, checksum:, name: "ActiveStorage-", tmpdir: nil)
11
+ def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil)
12
12
  open_tempfile(name, tmpdir) do |file|
13
13
  download key, file
14
- verify_integrity_of file, checksum: checksum
14
+ verify_integrity_of(file, checksum: checksum) if verify
15
15
  yield file
16
16
  end
17
17
  end
@@ -101,6 +101,8 @@ module ActiveStorage
101
101
  ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream"
102
102
  ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2"
103
103
 
104
+ ActiveStorage.silence_invalid_content_types_warning = app.config.active_storage.silence_invalid_content_types_warning || false
105
+
104
106
  ActiveStorage.replace_on_assign_to_many = app.config.active_storage.replace_on_assign_to_many || false
105
107
  ActiveStorage.track_variants = app.config.active_storage.track_variants || false
106
108
  end
@@ -26,4 +26,7 @@ module ActiveStorage
26
26
 
27
27
  # Raised when a Previewer is unable to generate a preview image.
28
28
  class PreviewError < Error; end
29
+
30
+ # Raised when direct upload fails because of the invalid token
31
+ class InvalidDirectUploadTokenError < Error; end
29
32
  end
@@ -10,7 +10,7 @@ module ActiveStorage
10
10
  MAJOR = 7
11
11
  MINOR = 0
12
12
  TINY = 0
13
- PRE = "alpha2"
13
+ PRE = "rc1"
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
16
  end
@@ -19,12 +19,12 @@ module ActiveStorage
19
19
  @public = public
20
20
  end
21
21
 
22
- def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
22
+ def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **)
23
23
  instrument :upload, key: key, checksum: checksum do
24
24
  handle_errors do
25
25
  content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
26
26
 
27
- client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition)
27
+ client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata)
28
28
  end
29
29
  end
30
30
  end
@@ -86,7 +86,7 @@ module ActiveStorage
86
86
  end
87
87
  end
88
88
 
89
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
89
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
90
90
  instrument :url, key: key do |payload|
91
91
  generated_url = signer.signed_uri(
92
92
  uri_for(key), false,
@@ -101,10 +101,28 @@ module ActiveStorage
101
101
  end
102
102
  end
103
103
 
104
- def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
104
+ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
105
105
  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
106
106
 
107
- { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob" }
107
+ { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) }
108
+ end
109
+
110
+ def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
111
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
112
+
113
+ client.create_append_blob(
114
+ container,
115
+ destination_key,
116
+ content_type: content_type,
117
+ content_disposition: content_disposition,
118
+ metadata: custom_metadata,
119
+ ).tap do |blob|
120
+ source_keys.each do |source_key|
121
+ stream(source_key) do |chunk|
122
+ client.append_blob_block(container, blob.name, chunk)
123
+ end
124
+ end
125
+ end
108
126
  end
109
127
 
110
128
  private
@@ -166,5 +184,9 @@ module ActiveStorage
166
184
  raise
167
185
  end
168
186
  end
187
+
188
+ def custom_metadata_headers(metadata)
189
+ metadata.transform_keys { |key| "x-ms-meta-#{key}" }
190
+ end
169
191
  end
170
192
  end
@@ -72,7 +72,7 @@ module ActiveStorage
72
72
  end
73
73
  end
74
74
 
75
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
75
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
76
76
  instrument :url, key: key do |payload|
77
77
  verified_token_with_expiration = ActiveStorage.verifier.generate(
78
78
  {
@@ -100,6 +100,16 @@ module ActiveStorage
100
100
  File.join root, folder_for(key), key
101
101
  end
102
102
 
103
+ def compose(source_keys, destination_key, **)
104
+ File.open(make_path_for(destination_key), "w") do |destination_file|
105
+ source_keys.each do |source_key|
106
+ File.open(path_for(source_key), "rb") do |source_file|
107
+ IO.copy_stream(source_file, destination_file)
108
+ end
109
+ end
110
+ end
111
+ end
112
+
103
113
  private
104
114
  def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
105
115
  generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
@@ -16,14 +16,14 @@ module ActiveStorage
16
16
  @public = public
17
17
  end
18
18
 
19
- def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
19
+ def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {})
20
20
  instrument :upload, key: key, checksum: checksum do
21
21
  # GCS's signed URLs don't include params such as response-content-type response-content_disposition
22
22
  # in the signature, which means an attacker can modify them and bypass our effort to force these to
23
23
  # binary and attachment when the file's content type requires it. The only way to force them is to
24
24
  # store them as object's metadata.
25
25
  content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
26
- bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition)
26
+ bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata)
27
27
  rescue Google::Cloud::InvalidArgumentError
28
28
  raise ActiveStorage::IntegrityError
29
29
  end
@@ -43,11 +43,12 @@ module ActiveStorage
43
43
  end
44
44
  end
45
45
 
46
- def update_metadata(key, content_type:, disposition: nil, filename: nil)
46
+ def update_metadata(key, content_type:, disposition: nil, filename: nil, custom_metadata: {})
47
47
  instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
48
48
  file_for(key).update do |file|
49
49
  file.content_type = content_type
50
50
  file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
51
+ file.metadata = custom_metadata
51
52
  end
52
53
  end
53
54
  end
@@ -86,7 +87,7 @@ module ActiveStorage
86
87
  end
87
88
  end
88
89
 
89
- def url_for_direct_upload(key, expires_in:, checksum:, **)
90
+ def url_for_direct_upload(key, expires_in:, checksum:, custom_metadata: {}, **)
90
91
  instrument :url, key: key do |payload|
91
92
  headers = {}
92
93
  version = :v2
@@ -99,6 +100,8 @@ module ActiveStorage
99
100
  version = :v4
100
101
  end
101
102
 
103
+ headers.merge!(custom_metadata_headers(custom_metadata))
104
+
102
105
  args = {
103
106
  content_md5: checksum,
104
107
  expires: expires_in,
@@ -120,11 +123,10 @@ module ActiveStorage
120
123
  end
121
124
  end
122
125
 
123
- def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, **)
126
+ def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
124
127
  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
125
128
 
126
- headers = { "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
127
-
129
+ headers = { "Content-MD5" => checksum, "Content-Disposition" => content_disposition, **custom_metadata_headers(custom_metadata) }
128
130
  if @config[:cache_control].present?
129
131
  headers["Cache-Control"] = @config[:cache_control]
130
132
  end
@@ -132,6 +134,14 @@ module ActiveStorage
132
134
  headers
133
135
  end
134
136
 
137
+ def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
138
+ bucket.compose(source_keys, destination_key).update do |file|
139
+ file.content_type = content_type
140
+ file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
141
+ file.metadata = custom_metadata
142
+ end
143
+ end
144
+
135
145
  private
136
146
  def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
137
147
  args = {
@@ -223,5 +233,9 @@ module ActiveStorage
223
233
  response.signed_blob
224
234
  end
225
235
  end
236
+
237
+ def custom_metadata_headers(metadata)
238
+ metadata.transform_keys { |key| "x-goog-meta-#{key}" }
239
+ end
226
240
  end
227
241
  end
@@ -14,7 +14,7 @@ module ActiveStorage
14
14
  attr_reader :primary, :mirrors
15
15
 
16
16
  delegate :download, :download_chunk, :exist?, :url,
17
- :url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
17
+ :url_for_direct_upload, :headers_for_direct_upload, :path_for, :compose, to: :primary
18
18
 
19
19
  # Stitch together from named services.
20
20
  def self.build(primary:, mirrors:, name:, configurator:, **options) # :nodoc:
@@ -23,14 +23,14 @@ module ActiveStorage
23
23
  @upload_options[:acl] = "public-read" if public?
24
24
  end
25
25
 
26
- def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
26
+ def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **)
27
27
  instrument :upload, key: key, checksum: checksum do
28
28
  content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
29
29
 
30
30
  if io.size < multipart_upload_threshold
31
- upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
31
+ upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition, custom_metadata: custom_metadata
32
32
  else
33
- upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
33
+ upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition, custom_metadata: custom_metadata
34
34
  end
35
35
  end
36
36
  end
@@ -77,11 +77,11 @@ module ActiveStorage
77
77
  end
78
78
  end
79
79
 
80
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
80
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
81
81
  instrument :url, key: key do |payload|
82
82
  generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
83
83
  content_type: content_type, content_length: content_length, content_md5: checksum,
84
- whitelist_headers: ["content-length"], **upload_options
84
+ metadata: custom_metadata, whitelist_headers: ["content-length"], **upload_options
85
85
 
86
86
  payload[:url] = generated_url
87
87
 
@@ -89,10 +89,28 @@ module ActiveStorage
89
89
  end
90
90
  end
91
91
 
92
- def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
92
+ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
93
93
  content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
94
94
 
95
- { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
95
+ { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition, **custom_metadata_headers(custom_metadata) }
96
+ end
97
+
98
+ def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
99
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
100
+
101
+ object_for(destination_key).upload_stream(
102
+ content_type: content_type,
103
+ content_disposition: content_disposition,
104
+ part_size: MINIMUM_UPLOAD_PART_SIZE,
105
+ metadata: custom_metadata,
106
+ **upload_options
107
+ ) do |out|
108
+ source_keys.each do |source_key|
109
+ stream(source_key) do |chunk|
110
+ IO.copy_stream(StringIO.new(chunk), out)
111
+ end
112
+ end
113
+ end
96
114
  end
97
115
 
98
116
  private
@@ -110,16 +128,16 @@ module ActiveStorage
110
128
  MAXIMUM_UPLOAD_PARTS_COUNT = 10000
111
129
  MINIMUM_UPLOAD_PART_SIZE = 5.megabytes
112
130
 
113
- def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil)
114
- object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, **upload_options)
131
+ def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil, custom_metadata: {})
132
+ object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, **upload_options)
115
133
  rescue Aws::S3::Errors::BadDigest
116
134
  raise ActiveStorage::IntegrityError
117
135
  end
118
136
 
119
- def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
137
+ def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {})
120
138
  part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
121
139
 
122
- object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
140
+ object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options) do |out|
123
141
  IO.copy_stream(io, out)
124
142
  end
125
143
  end
@@ -143,5 +161,9 @@ module ActiveStorage
143
161
  offset += chunk_size
144
162
  end
145
163
  end
164
+
165
+ def custom_metadata_headers(metadata)
166
+ metadata.transform_keys { |key| "x-amz-meta-#{key}" }
167
+ end
146
168
  end
147
169
  end
@@ -90,6 +90,11 @@ module ActiveStorage
90
90
  ActiveStorage::Downloader.new(self).open(*args, **options, &block)
91
91
  end
92
92
 
93
+ # Concatenate multiple files into a single "composed" file.
94
+ def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
95
+ raise NotImplementedError
96
+ end
97
+
93
98
  # Delete the file at the +key+.
94
99
  def delete(key)
95
100
  raise NotImplementedError
@@ -128,12 +133,12 @@ module ActiveStorage
128
133
  # The URL will be valid for the amount of seconds specified in +expires_in+.
129
134
  # You must also provide the +content_type+, +content_length+, and +checksum+ of the file
130
135
  # that will be uploaded. All these attributes will be validated by the service upon upload.
131
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
136
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
132
137
  raise NotImplementedError
133
138
  end
134
139
 
135
140
  # Returns a Hash of headers for +url_for_direct_upload+ requests.
136
- def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
141
+ def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:, custom_metadata: {})
137
142
  {}
138
143
  end
139
144
 
@@ -150,6 +155,9 @@ module ActiveStorage
150
155
  raise NotImplementedError
151
156
  end
152
157
 
158
+ def custom_metadata_headers(metadata)
159
+ raise NotImplementedError
160
+ end
153
161
 
154
162
  def instrument(operation, payload = {}, &block)
155
163
  ActiveSupport::Notifications.instrument(
@@ -41,6 +41,7 @@ module ActiveStorage
41
41
  autoload :Service
42
42
  autoload :Previewer
43
43
  autoload :Analyzer
44
+ autoload :DirectUploadToken
44
45
 
45
46
  mattr_accessor :logger
46
47
  mattr_accessor :verifier
@@ -71,6 +72,8 @@ module ActiveStorage
71
72
 
72
73
  mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2"
73
74
 
75
+ mattr_accessor :silence_invalid_content_types_warning, default: false
76
+
74
77
  module Transformers
75
78
  extend ActiveSupport::Autoload
76
79
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activestorage
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.0.alpha2
4
+ version: 7.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-15 00:00:00.000000000 Z
11
+ date: 2021-12-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,70 +16,70 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 7.0.0.alpha2
19
+ version: 7.0.0.rc1
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 7.0.0.alpha2
26
+ version: 7.0.0.rc1
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: actionpack
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - '='
32
32
  - !ruby/object:Gem::Version
33
- version: 7.0.0.alpha2
33
+ version: 7.0.0.rc1
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - '='
39
39
  - !ruby/object:Gem::Version
40
- version: 7.0.0.alpha2
40
+ version: 7.0.0.rc1
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: activejob
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: 7.0.0.alpha2
47
+ version: 7.0.0.rc1
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: 7.0.0.alpha2
54
+ version: 7.0.0.rc1
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: activerecord
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - '='
60
60
  - !ruby/object:Gem::Version
61
- version: 7.0.0.alpha2
61
+ version: 7.0.0.rc1
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - '='
67
67
  - !ruby/object:Gem::Version
68
- version: 7.0.0.alpha2
68
+ version: 7.0.0.rc1
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: marcel
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: 1.0.0
75
+ version: '1.0'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: 1.0.0
82
+ version: '1.0'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: mini_mime
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -147,6 +147,7 @@ files:
147
147
  - db/migrate/20170806125915_create_active_storage_tables.rb
148
148
  - db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb
149
149
  - db/update_migrate/20191206030411_create_active_storage_variant_records.rb
150
+ - db/update_migrate/20211119233751_remove_not_null_on_active_storage_blobs_checksum.rb
150
151
  - lib/active_storage.rb
151
152
  - lib/active_storage/analyzer.rb
152
153
  - lib/active_storage/analyzer/audio_analyzer.rb
@@ -169,6 +170,7 @@ files:
169
170
  - lib/active_storage/attached/many.rb
170
171
  - lib/active_storage/attached/model.rb
171
172
  - lib/active_storage/attached/one.rb
173
+ - lib/active_storage/direct_upload_token.rb
172
174
  - lib/active_storage/downloader.rb
173
175
  - lib/active_storage/engine.rb
174
176
  - lib/active_storage/errors.rb
@@ -197,10 +199,11 @@ licenses:
197
199
  - MIT
198
200
  metadata:
199
201
  bug_tracker_uri: https://github.com/rails/rails/issues
200
- changelog_uri: https://github.com/rails/rails/blob/v7.0.0.alpha2/activestorage/CHANGELOG.md
201
- documentation_uri: https://api.rubyonrails.org/v7.0.0.alpha2/
202
+ changelog_uri: https://github.com/rails/rails/blob/v7.0.0.rc1/activestorage/CHANGELOG.md
203
+ documentation_uri: https://api.rubyonrails.org/v7.0.0.rc1/
202
204
  mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
203
- source_code_uri: https://github.com/rails/rails/tree/v7.0.0.alpha2/activestorage
205
+ source_code_uri: https://github.com/rails/rails/tree/v7.0.0.rc1/activestorage
206
+ rubygems_mfa_required: 'true'
204
207
  post_install_message:
205
208
  rdoc_options: []
206
209
  require_paths:
@@ -216,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
219
  - !ruby/object:Gem::Version
217
220
  version: 1.3.1
218
221
  requirements: []
219
- rubygems_version: 3.1.6
222
+ rubygems_version: 3.2.22
220
223
  signing_key:
221
224
  specification_version: 4
222
225
  summary: Local and cloud file storage framework.