activestorage 5.2.4.4 → 6.1.1

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.

Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -69
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +43 -8
  5. data/app/assets/javascripts/activestorage.js +5 -2
  6. data/app/controllers/active_storage/base_controller.rb +13 -4
  7. data/app/controllers/active_storage/blobs/proxy_controller.rb +14 -0
  8. data/app/controllers/active_storage/{blobs_controller.rb → blobs/redirect_controller.rb} +3 -3
  9. data/app/controllers/active_storage/direct_uploads_controller.rb +2 -2
  10. data/app/controllers/active_storage/disk_controller.rb +13 -22
  11. data/app/controllers/active_storage/representations/proxy_controller.rb +19 -0
  12. data/app/controllers/active_storage/{representations_controller.rb → representations/redirect_controller.rb} +3 -3
  13. data/app/controllers/concerns/active_storage/file_server.rb +18 -0
  14. data/app/controllers/concerns/active_storage/set_blob.rb +1 -1
  15. data/app/controllers/concerns/active_storage/set_current.rb +15 -0
  16. data/app/controllers/concerns/active_storage/set_headers.rb +12 -0
  17. data/app/javascript/activestorage/blob_record.js +7 -2
  18. data/app/jobs/active_storage/analyze_job.rb +5 -0
  19. data/app/jobs/active_storage/base_job.rb +0 -1
  20. data/app/jobs/active_storage/mirror_job.rb +15 -0
  21. data/app/jobs/active_storage/purge_job.rb +3 -0
  22. data/app/models/active_storage/attachment.rb +35 -16
  23. data/app/models/active_storage/blob.rb +178 -68
  24. data/app/models/active_storage/blob/analyzable.rb +6 -2
  25. data/app/models/active_storage/blob/identifiable.rb +7 -6
  26. data/app/models/active_storage/blob/representable.rb +36 -6
  27. data/app/models/active_storage/filename.rb +0 -6
  28. data/app/models/active_storage/preview.rb +37 -12
  29. data/app/models/active_storage/record.rb +7 -0
  30. data/app/models/active_storage/variant.rb +53 -67
  31. data/app/models/active_storage/variant_record.rb +8 -0
  32. data/app/models/active_storage/variant_with_record.rb +54 -0
  33. data/app/models/active_storage/variation.rb +30 -34
  34. data/config/routes.rb +66 -15
  35. data/db/migrate/20170806125915_create_active_storage_tables.rb +14 -5
  36. data/db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb +17 -0
  37. data/db/update_migrate/20191206030411_create_active_storage_variant_records.rb +11 -0
  38. data/lib/active_storage.rb +29 -6
  39. data/lib/active_storage/analyzer.rb +15 -4
  40. data/lib/active_storage/analyzer/image_analyzer.rb +14 -4
  41. data/lib/active_storage/analyzer/null_analyzer.rb +4 -0
  42. data/lib/active_storage/analyzer/video_analyzer.rb +17 -8
  43. data/lib/active_storage/attached.rb +7 -22
  44. data/lib/active_storage/attached/changes.rb +16 -0
  45. data/lib/active_storage/attached/changes/create_many.rb +47 -0
  46. data/lib/active_storage/attached/changes/create_one.rb +82 -0
  47. data/lib/active_storage/attached/changes/create_one_of_many.rb +10 -0
  48. data/lib/active_storage/attached/changes/delete_many.rb +27 -0
  49. data/lib/active_storage/attached/changes/delete_one.rb +19 -0
  50. data/lib/active_storage/attached/many.rb +19 -12
  51. data/lib/active_storage/attached/model.rb +212 -0
  52. data/lib/active_storage/attached/one.rb +19 -21
  53. data/lib/active_storage/downloader.rb +43 -0
  54. data/lib/active_storage/engine.rb +58 -23
  55. data/lib/active_storage/errors.rb +22 -3
  56. data/lib/active_storage/gem_version.rb +4 -4
  57. data/lib/active_storage/log_subscriber.rb +6 -0
  58. data/lib/active_storage/previewer.rb +24 -13
  59. data/lib/active_storage/previewer/mupdf_previewer.rb +3 -3
  60. data/lib/active_storage/previewer/poppler_pdf_previewer.rb +5 -5
  61. data/lib/active_storage/previewer/video_previewer.rb +17 -10
  62. data/lib/active_storage/reflection.rb +64 -0
  63. data/lib/active_storage/service.rb +44 -12
  64. data/lib/active_storage/service/azure_storage_service.rb +65 -44
  65. data/lib/active_storage/service/configurator.rb +6 -2
  66. data/lib/active_storage/service/disk_service.rb +57 -44
  67. data/lib/active_storage/service/gcs_service.rb +68 -64
  68. data/lib/active_storage/service/mirror_service.rb +31 -7
  69. data/lib/active_storage/service/registry.rb +32 -0
  70. data/lib/active_storage/service/s3_service.rb +58 -24
  71. data/lib/active_storage/transformers/image_processing_transformer.rb +45 -0
  72. data/lib/active_storage/transformers/transformer.rb +39 -0
  73. data/lib/tasks/activestorage.rake +7 -0
  74. metadata +84 -19
  75. data/app/models/active_storage/filename/parameters.rb +0 -36
  76. data/lib/active_storage/attached/macros.rb +0 -110
  77. data/lib/active_storage/downloading.rb +0 -39
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The base controller for all ActiveStorage controllers.
3
+ # The base class for all Active Storage controllers.
4
4
  class ActiveStorage::BaseController < ActionController::Base
5
+ include ActiveStorage::SetCurrent
6
+
5
7
  protect_from_forgery with: :exception
6
8
 
7
- before_action do
8
- ActiveStorage::Current.host = request.base_url
9
- end
9
+ self.etag_with_template_digest = false
10
+
11
+ private
12
+ def stream(blob)
13
+ blob.download do |chunk|
14
+ response.stream.write chunk
15
+ end
16
+ ensure
17
+ response.stream.close
18
+ end
10
19
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Proxy files through application. This avoids having a redirect and makes files easier to cache.
4
+ class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
5
+ include ActiveStorage::SetBlob
6
+ include ActiveStorage::SetHeaders
7
+
8
+ def show
9
+ http_cache_forever public: true do
10
+ set_content_headers_from @blob
11
+ stream @blob
12
+ end
13
+ end
14
+ end
@@ -4,11 +4,11 @@
4
4
  # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
5
5
  # security-through-obscurity factor of the signed blob references, you'll need to implement your own
6
6
  # authenticated redirection controller.
7
- class ActiveStorage::BlobsController < ActiveStorage::BaseController
7
+ class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
8
8
  include ActiveStorage::SetBlob
9
9
 
10
10
  def show
11
- expires_in ActiveStorage::Blob.service.url_expires_in
12
- redirect_to @blob.service_url(disposition: params[:disposition])
11
+ expires_in ActiveStorage.service_urls_expire_in
12
+ redirect_to @blob.url(disposition: params[:disposition])
13
13
  end
14
14
  end
@@ -5,13 +5,13 @@
5
5
  # the blob that was created up front.
6
6
  class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
7
7
  def create
8
- blob = ActiveStorage::Blob.create_before_direct_upload!(blob_args)
8
+ blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
9
9
  render json: direct_upload_json(blob)
10
10
  end
11
11
 
12
12
  private
13
13
  def blob_args
14
- params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :metadata).to_h.symbolize_keys
14
+ params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys
15
15
  end
16
16
 
17
17
  def direct_upload_json(blob)
@@ -3,56 +3,47 @@
3
3
  # Serves files stored with the disk service in the same way that the cloud services do.
4
4
  # This means using expiring, signed URLs that are meant for immediate access, not permanent linking.
5
5
  # Always go through the BlobsController, or your own authenticated controller, rather than directly
6
- # to the service url.
6
+ # to the service URL.
7
7
  class ActiveStorage::DiskController < ActiveStorage::BaseController
8
+ include ActiveStorage::FileServer
9
+
8
10
  skip_forgery_protection
9
11
 
10
12
  def show
11
13
  if key = decode_verified_key
12
- serve_file disk_service.path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
14
+ serve_file named_disk_service(key[:service_name]).path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
13
15
  else
14
16
  head :not_found
15
17
  end
18
+ rescue Errno::ENOENT
19
+ head :not_found
16
20
  end
17
21
 
18
22
  def update
19
23
  if token = decode_verified_token
20
24
  if acceptable_content?(token)
21
- disk_service.upload token[:key], request.body, checksum: token[:checksum]
22
- head :no_content
25
+ named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
23
26
  else
24
27
  head :unprocessable_entity
25
28
  end
29
+ else
30
+ head :not_found
26
31
  end
27
32
  rescue ActiveStorage::IntegrityError
28
33
  head :unprocessable_entity
29
34
  end
30
35
 
31
36
  private
32
- def disk_service
33
- ActiveStorage::Blob.service
37
+ def named_disk_service(name)
38
+ ActiveStorage::Blob.services.fetch(name) do
39
+ ActiveStorage::Blob.service
40
+ end
34
41
  end
35
42
 
36
-
37
43
  def decode_verified_key
38
44
  ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
39
45
  end
40
46
 
41
- def serve_file(path, content_type:, disposition:)
42
- Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
43
- self.status = status
44
- self.response_body = body
45
-
46
- headers.each do |name, value|
47
- response.headers[name] = value
48
- end
49
-
50
- response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
51
- response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
52
- end
53
- end
54
-
55
-
56
47
  def decode_verified_token
57
48
  ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
58
49
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Proxy files through application. This avoids having a redirect and makes files easier to cache.
4
+ class ActiveStorage::Representations::ProxyController < ActiveStorage::BaseController
5
+ include ActiveStorage::SetBlob
6
+ include ActiveStorage::SetHeaders
7
+
8
+ def show
9
+ http_cache_forever public: true do
10
+ set_content_headers_from representation.image
11
+ stream representation
12
+ end
13
+ end
14
+
15
+ private
16
+ def representation
17
+ @representation ||= @blob.representation(params[:variation_key]).processed
18
+ end
19
+ end
@@ -4,11 +4,11 @@
4
4
  # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
5
5
  # security-through-obscurity factor of the signed blob and variation reference, you'll need to implement your own
6
6
  # authenticated redirection controller.
7
- class ActiveStorage::RepresentationsController < ActiveStorage::BaseController
7
+ class ActiveStorage::Representations::RedirectController < ActiveStorage::BaseController
8
8
  include ActiveStorage::SetBlob
9
9
 
10
10
  def show
11
- expires_in ActiveStorage::Blob.service.url_expires_in
12
- redirect_to @blob.representation(params[:variation_key]).processed.service_url(disposition: params[:disposition])
11
+ expires_in ActiveStorage.service_urls_expire_in
12
+ redirect_to @blob.representation(params[:variation_key]).processed.url(disposition: params[:disposition])
13
13
  end
14
14
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage::FileServer # :nodoc:
4
+ private
5
+ def serve_file(path, content_type:, disposition:)
6
+ Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
7
+ self.status = status
8
+ self.response_body = body
9
+
10
+ headers.each do |name, value|
11
+ response.headers[name] = value
12
+ end
13
+
14
+ response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
15
+ response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
16
+ end
17
+ end
18
+ end
@@ -9,7 +9,7 @@ module ActiveStorage::SetBlob #:nodoc:
9
9
 
10
10
  private
11
11
  def set_blob
12
- @blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
12
+ @blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
13
13
  rescue ActiveSupport::MessageVerifier::InvalidSignature
14
14
  head :not_found
15
15
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sets the <tt>ActiveStorage::Current.host</tt> attribute, which the disk service uses to generate URLs.
4
+ # Include this concern in custom controllers that call ActiveStorage::Blob#url,
5
+ # ActiveStorage::Variant#url, or ActiveStorage::Preview#url so the disk service can
6
+ # generate URLs using the same host, protocol, and base path as the current request.
7
+ module ActiveStorage::SetCurrent
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ before_action do
12
+ ActiveStorage::Current.host = request.base_url
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage::SetHeaders #:nodoc:
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+ def set_content_headers_from(blob)
8
+ response.headers["Content-Type"] = blob.content_type_for_serving
9
+ response.headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format \
10
+ disposition: blob.forced_disposition_for_serving || params[:disposition] || "inline", filename: blob.filename.sanitized
11
+ end
12
+ end
@@ -6,7 +6,7 @@ export class BlobRecord {
6
6
 
7
7
  this.attributes = {
8
8
  filename: file.name,
9
- content_type: file.type,
9
+ content_type: file.type || "application/octet-stream",
10
10
  byte_size: file.size,
11
11
  checksum: checksum
12
12
  }
@@ -17,7 +17,12 @@ export class BlobRecord {
17
17
  this.xhr.setRequestHeader("Content-Type", "application/json")
18
18
  this.xhr.setRequestHeader("Accept", "application/json")
19
19
  this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
20
- this.xhr.setRequestHeader("X-CSRF-Token", getMetaValue("csrf-token"))
20
+
21
+ const csrfToken = getMetaValue("csrf-token")
22
+ if (csrfToken != undefined) {
23
+ this.xhr.setRequestHeader("X-CSRF-Token", csrfToken)
24
+ }
25
+
21
26
  this.xhr.addEventListener("load", event => this.requestDidLoad(event))
22
27
  this.xhr.addEventListener("error", event => this.requestDidError(event))
23
28
  }
@@ -2,6 +2,11 @@
2
2
 
3
3
  # Provides asynchronous analysis of ActiveStorage::Blob records via ActiveStorage::Blob#analyze_later.
4
4
  class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob
5
+ queue_as { ActiveStorage.queues[:analysis] }
6
+
7
+ discard_on ActiveRecord::RecordNotFound
8
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
9
+
5
10
  def perform(blob)
6
11
  blob.analyze
7
12
  end
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ActiveStorage::BaseJob < ActiveJob::Base
4
- queue_as { ActiveStorage.queue }
5
4
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/try"
4
+
5
+ # Provides asynchronous mirroring of directly-uploaded blobs.
6
+ class ActiveStorage::MirrorJob < ActiveStorage::BaseJob
7
+ queue_as { ActiveStorage.queues[:mirror] }
8
+
9
+ discard_on ActiveStorage::FileNotFoundError
10
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
11
+
12
+ def perform(key, checksum:)
13
+ ActiveStorage::Blob.service.try(:mirror, key, checksum: checksum)
14
+ end
15
+ end
@@ -2,7 +2,10 @@
2
2
 
3
3
  # Provides asynchronous purging of ActiveStorage::Blob records via ActiveStorage::Blob#purge_later.
4
4
  class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
5
+ queue_as { ActiveStorage.queues[:purge] }
6
+
5
7
  discard_on ActiveRecord::RecordNotFound
8
+ retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer
6
9
 
7
10
  def perform(blob)
8
11
  blob.purge
@@ -3,37 +3,56 @@
3
3
  require "active_support/core_ext/module/delegation"
4
4
 
5
5
  # Attachments associate records with blobs. Usually that's a one record-many blobs relationship,
6
- # but it is possible to associate many different records with the same blob. If you're doing that,
7
- # you'll want to declare with <tt>has_one/many_attached :thingy, dependent: false</tt>, so that destroying
8
- # any one record won't destroy the blob as well. (Then you'll need to do your own garbage collecting, though).
9
- class ActiveStorage::Attachment < ActiveRecord::Base
6
+ # but it is possible to associate many different records with the same blob. A foreign-key constraint
7
+ # on the attachments table prevents blobs from being purged if they’re still attached to any records.
8
+ #
9
+ # Attachments also have access to all methods from {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob].
10
+ class ActiveStorage::Attachment < ActiveStorage::Record
10
11
  self.table_name = "active_storage_attachments"
11
12
 
12
13
  belongs_to :record, polymorphic: true, touch: true
13
- belongs_to :blob, class_name: "ActiveStorage::Blob"
14
+ belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true
14
15
 
15
16
  delegate_missing_to :blob
17
+ delegate :signed_id, to: :blob
16
18
 
17
- after_create_commit :analyze_blob_later, :identify_blob
19
+ after_create_commit :mirror_blob_later, :analyze_blob_later
20
+ after_destroy_commit :purge_dependent_blob_later
18
21
 
19
- # Synchronously purges the blob (deletes it from the configured service) and destroys the attachment.
22
+ # Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge].
20
23
  def purge
21
- destroy
22
- blob.purge
24
+ transaction do
25
+ delete
26
+ record&.touch
27
+ end
28
+ blob&.purge
23
29
  end
24
30
 
25
- # Destroys the attachment and asynchronously purges the blob (deletes it from the configured service).
31
+ # Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob.
26
32
  def purge_later
27
- destroy
28
- blob.purge_later
33
+ transaction do
34
+ delete
35
+ record&.touch
36
+ end
37
+ blob&.purge_later
29
38
  end
30
39
 
31
40
  private
32
- def identify_blob
33
- blob.identify
34
- end
35
-
36
41
  def analyze_blob_later
37
42
  blob.analyze_later unless blob.analyzed?
38
43
  end
44
+
45
+ def mirror_blob_later
46
+ blob.mirror_later
47
+ end
48
+
49
+ def purge_dependent_blob_later
50
+ blob&.purge_later if dependent == :purge_later
51
+ end
52
+
53
+ def dependent
54
+ record.attachment_reflections[name]&.options[:dependent]
55
+ end
39
56
  end
57
+
58
+ ActiveSupport.run_load_hooks :active_storage_attachment, ActiveStorage::Attachment
@@ -3,8 +3,9 @@
3
3
  # A blob is a record that contains the metadata about a file and a key for where that file resides on the service.
4
4
  # Blobs can be created in two ways:
5
5
  #
6
- # 1. Subsequent to the file being uploaded server-side to the service via <tt>create_after_upload!</tt>.
7
- # 2. Ahead of the file being directly uploaded client-side to the service via <tt>create_before_direct_upload!</tt>.
6
+ # 1. Ahead of the file being uploaded server-side to the service, via <tt>create_and_upload!</tt>. A rewindable
7
+ # <tt>io</tt> with the file contents must be available at the server for this operation.
8
+ # 2. Ahead of the file being directly uploaded client-side to the service, via <tt>create_before_direct_upload!</tt>.
8
9
  #
9
10
  # The first option doesn't require any client-side JavaScript integration, and can be used by any other back-end
10
11
  # service that deals with files. The second option is faster, since you're not using your own server as a staging
@@ -13,80 +14,146 @@
13
14
  # Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to
14
15
  # update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file.
15
16
  # If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one.
16
- class ActiveStorage::Blob < ActiveRecord::Base
17
- require_dependency "active_storage/blob/analyzable"
18
- require_dependency "active_storage/blob/identifiable"
19
- require_dependency "active_storage/blob/representable"
20
-
21
- include Analyzable
22
- include Identifiable
23
- include Representable
17
+ class ActiveStorage::Blob < ActiveStorage::Record
18
+ # We use constant paths in the following include calls to avoid a gotcha of
19
+ # classic mode: If the parent application defines a top-level Analyzable, for
20
+ # example, and ActiveStorage::Blob::Analyzable is not yet loaded, a bare
21
+ #
22
+ # include Analyzable
23
+ #
24
+ # would resolve to the top-level one, const_missing would not be triggered,
25
+ # and therefore ActiveStorage::Blob::Analyzable would not be autoloaded.
26
+ #
27
+ # By using qualified names, we ensure const_missing is invoked if needed.
28
+ # Please, note that Ruby 2.5 or newer is required, so Object is not checked
29
+ # when looking up the ancestors of ActiveStorage::Blob.
30
+ #
31
+ # Zeitwerk mode does not have this gotcha. If we ever drop classic mode, this
32
+ # can be simplified, bare constant names would just work.
33
+ include ActiveStorage::Blob::Analyzable
34
+ include ActiveStorage::Blob::Identifiable
35
+ include ActiveStorage::Blob::Representable
24
36
 
25
37
  self.table_name = "active_storage_blobs"
26
38
 
27
- has_secure_token :key
39
+ MINIMUM_TOKEN_LENGTH = 28
40
+
41
+ has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
28
42
  store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
29
43
 
30
- class_attribute :service
44
+ class_attribute :services, default: {}
45
+ class_attribute :service, instance_accessor: false
31
46
 
32
47
  has_many :attachments
33
48
 
34
- scope :unattached, -> { left_joins(:attachments).where(ActiveStorage::Attachment.table_name => { blob_id: nil }) }
49
+ scope :unattached, -> { where.missing(:attachments) }
50
+
51
+ after_initialize do
52
+ self.service_name ||= self.class.service.name
53
+ end
54
+
55
+ after_update_commit :update_service_metadata, if: :content_type_previously_changed?
35
56
 
36
57
  before_destroy(prepend: true) do
37
58
  raise ActiveRecord::InvalidForeignKey if attachments.exists?
38
59
  end
39
60
 
61
+ validates :service_name, presence: true
62
+
63
+ validate do
64
+ if service_name_changed? && service_name.present?
65
+ services.fetch(service_name) do
66
+ errors.add(:service_name, :invalid)
67
+ end
68
+ end
69
+ end
70
+
40
71
  class << self
41
- # You can used the signed ID of a blob to refer to it on the client side without fear of tampering.
72
+ # You can use the signed ID of a blob to refer to it on the client side without fear of tampering.
42
73
  # This is particularly helpful for direct uploads where the client-side needs to refer to the blob
43
74
  # that was created ahead of the upload itself on form submission.
44
75
  #
45
76
  # The signed ID is also used to create stable URLs for the blob through the BlobsController.
46
- def find_signed(id)
47
- find ActiveStorage.verifier.verify(id, purpose: :blob_id)
77
+ def find_signed!(id, record: nil)
78
+ super(id, purpose: :blob_id)
48
79
  end
49
80
 
50
- # Returns a new, unsaved blob instance after the +io+ has been uploaded to the service.
51
- def build_after_upload(io:, filename:, content_type: nil, metadata: nil)
52
- new.tap do |blob|
53
- blob.filename = filename
54
- blob.content_type = content_type
55
- blob.metadata = metadata
81
+ def build_after_upload(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
82
+ new(filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
83
+ blob.upload(io, identify: identify)
84
+ end
85
+ end
56
86
 
57
- blob.upload io
87
+ deprecate :build_after_upload
88
+
89
+ def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
90
+ new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
91
+ blob.unfurl(io, identify: identify)
58
92
  end
59
93
  end
60
94
 
61
- # Returns a saved blob instance after the +io+ has been uploaded to the service. Note, the blob is first built,
62
- # then the +io+ is uploaded, then the blob is saved. This is done this way to avoid uploading (which may take
63
- # time), while having an open database transaction.
64
- def create_after_upload!(io:, filename:, content_type: nil, metadata: nil)
65
- build_after_upload(io: io, filename: filename, content_type: content_type, metadata: metadata).tap(&:save!)
95
+ def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
96
+ build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap(&:save!)
66
97
  end
67
98
 
99
+ # Creates a new blob instance and then uploads the contents of
100
+ # the given <tt>io</tt> to the service. The blob instance is going to
101
+ # be saved before the upload begins to prevent the upload clobbering another due to key collisions.
102
+ # When providing a content type, pass <tt>identify: false</tt> to bypass
103
+ # automatic content type inference.
104
+ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
105
+ create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob|
106
+ blob.upload_without_unfurling(io)
107
+ end
108
+ end
109
+
110
+ alias_method :create_after_upload!, :create_and_upload!
111
+ deprecate create_after_upload!: :create_and_upload!
112
+
68
113
  # Returns a saved blob _without_ uploading a file to the service. This blob will point to a key where there is
69
114
  # no file yet. It's intended to be used together with a client-side upload, which will first create the blob
70
115
  # in order to produce the signed URL for uploading. This signed URL points to the key generated by the blob.
71
116
  # Once the form using the direct upload is submitted, the blob can be associated with the right record using
72
117
  # the signed ID.
73
- def create_before_direct_upload!(filename:, byte_size:, checksum:, content_type: nil, metadata: nil)
74
- create! filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata
118
+ def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
119
+ create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
120
+ end
121
+
122
+ # To prevent problems with case-insensitive filesystems, especially in combination
123
+ # with databases which treat indices as case-sensitive, all blob keys generated are going
124
+ # to only contain the base-36 character alphabet and will therefore be lowercase. To maintain
125
+ # the same or higher amount of entropy as in the base-58 encoding used by `has_secure_token`
126
+ # the number of bytes used is increased to 28 from the standard 24
127
+ def generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
128
+ SecureRandom.base36(length)
129
+ end
130
+
131
+ # Customize signed ID purposes for backwards compatibility.
132
+ def combine_signed_id_purposes(purpose) #:nodoc:
133
+ purpose.to_s
134
+ end
135
+
136
+ # Customize the default signed ID verifier for backwards compatibility.
137
+ #
138
+ # We override the reader (.signed_id_verifier) instead of just calling the writer (.signed_id_verifier=)
139
+ # to guard against the case where ActiveStorage.verifier isn't yet initialized at load time.
140
+ def signed_id_verifier #:nodoc:
141
+ @signed_id_verifier ||= ActiveStorage.verifier
75
142
  end
76
143
  end
77
144
 
78
145
  # Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
79
- # It uses the framework-wide verifier on <tt>ActiveStorage.verifier</tt>, but with a dedicated purpose.
80
146
  def signed_id
81
- ActiveStorage.verifier.generate(id, purpose: :blob_id)
147
+ super(purpose: :blob_id)
82
148
  end
83
149
 
84
- # Returns the key pointing to the file on the service that's associated with this blob. The key is in the
85
- # standard secure-token format from Rails. So it'll look like: XTAPjJCJiuDrLk3TmwyJGpUo. This key is not intended
86
- # to be revealed directly to the user. Always refer to blobs using the signed_id or a verified form of the key.
150
+ # Returns the key pointing to the file on the service that's associated with this blob. The key is the
151
+ # secure-token format from Rails in lower case. So it'll look like: xtapjjcjiudrlk3tmwyjgpuobabd.
152
+ # This key is not intended to be revealed directly to the user.
153
+ # Always refer to blobs using the signed_id or a verified form of the key.
87
154
  def key
88
155
  # We can't wait until the record is first saved to have a key for it
89
- self[:key] ||= self.class.generate_unique_secure_token
156
+ self[:key] ||= self.class.generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
90
157
  end
91
158
 
92
159
  # Returns an ActiveStorage::Filename instance of the filename that can be
@@ -116,21 +183,21 @@ class ActiveStorage::Blob < ActiveRecord::Base
116
183
  content_type.start_with?("text")
117
184
  end
118
185
 
119
-
120
- # Returns the URL of the blob on the service. This URL is intended to be short-lived for security and not used directly
121
- # with users. Instead, the +service_url+ should only be exposed as a redirect from a stable, possibly authenticated URL.
122
- # Hiding the +service_url+ behind a redirect also gives you the power to change services without updating all URLs. And
123
- # it allows permanent URLs that redirect to the +service_url+ to be cached in the view.
124
- def service_url(expires_in: service.url_expires_in, disposition: :inline, filename: nil, **options)
125
- filename = ActiveStorage::Filename.wrap(filename || self.filename)
126
-
127
- service.url key, expires_in: expires_in, filename: filename, content_type: content_type_for_service_url,
128
- disposition: forced_disposition_for_service_url || disposition, **options
186
+ # Returns the URL of the blob on the service. This returns a permanent URL for public files, and returns a
187
+ # short-lived URL for private files. Private files are signed, and not for public use. Instead,
188
+ # the URL should only be exposed as a redirect from a stable, possibly authenticated URL. Hiding the
189
+ # URL behind a redirect also allows you to change services without updating all URLs.
190
+ def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
191
+ service.url key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
192
+ content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options
129
193
  end
130
194
 
195
+ alias_method :service_url, :url
196
+ deprecate service_url: :url
197
+
131
198
  # Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
132
199
  # short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
133
- def service_url_for_direct_upload(expires_in: service.url_expires_in)
200
+ def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
134
201
  service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
135
202
  end
136
203
 
@@ -139,6 +206,16 @@ class ActiveStorage::Blob < ActiveRecord::Base
139
206
  service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
140
207
  end
141
208
 
209
+ def content_type_for_serving #:nodoc:
210
+ forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : content_type
211
+ end
212
+
213
+ def forced_disposition_for_serving #:nodoc:
214
+ if forcibly_serve_as_binary? || !allowed_inline?
215
+ :attachment
216
+ end
217
+ end
218
+
142
219
 
143
220
  # Uploads the +io+ to the service on the +key+ for this blob. Blobs are intended to be immutable, so you shouldn't be
144
221
  # using this method after a file has already been uploaded to fit with a blob. If you want to create a derivative blob,
@@ -146,16 +223,25 @@ class ActiveStorage::Blob < ActiveRecord::Base
146
223
  #
147
224
  # Prior to uploading, we compute the checksum, which is sent to the service for transit integrity validation. If the
148
225
  # checksum does not match what the service receives, an exception will be raised. We also measure the size of the +io+
149
- # and store that in +byte_size+ on the blob record.
226
+ # and store that in +byte_size+ on the blob record. The content type is automatically extracted from the +io+ unless
227
+ # you specify a +content_type+ and pass +identify+ as false.
150
228
  #
151
- # Normally, you do not have to call this method directly at all. Use the factory class methods of +build_after_upload+
152
- # and +create_after_upload!+.
153
- def upload(io)
229
+ # Normally, you do not have to call this method directly at all. Use the +create_and_upload!+ class method instead.
230
+ # If you do use this method directly, make sure you are using it on a persisted Blob as otherwise another blob's
231
+ # data might get overwritten on the service.
232
+ def upload(io, identify: true)
233
+ unfurl io, identify: identify
234
+ upload_without_unfurling io
235
+ end
236
+
237
+ def unfurl(io, identify: true) #:nodoc:
154
238
  self.checksum = compute_checksum_in_chunks(io)
155
- self.content_type = extract_content_type(io)
239
+ self.content_type = extract_content_type(io) if content_type.nil? || identify
156
240
  self.byte_size = io.size
157
241
  self.identified = true
242
+ end
158
243
 
244
+ def upload_without_unfurling(io) #:nodoc:
159
245
  service.upload key, io, checksum: checksum, **service_metadata
160
246
  end
161
247
 
@@ -165,30 +251,56 @@ class ActiveStorage::Blob < ActiveRecord::Base
165
251
  service.download key, &block
166
252
  end
167
253
 
254
+ # Downloads the blob to a tempfile on disk. Yields the tempfile.
255
+ #
256
+ # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob.
257
+ #
258
+ # By default, the tempfile is created in <tt>Dir.tmpdir</tt>. Pass +tmpdir:+ to create it in a different directory:
259
+ #
260
+ # blob.open(tmpdir: "/path/to/tmp") do |file|
261
+ # # ...
262
+ # end
263
+ #
264
+ # The tempfile is automatically closed and unlinked after the given block is executed.
265
+ #
266
+ # Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum.
267
+ def open(tmpdir: nil, &block)
268
+ service.open key, checksum: checksum,
269
+ name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ], tmpdir: tmpdir, &block
270
+ end
168
271
 
169
- # Deletes the file on the service that's associated with this blob. This should only be done if the blob is going to be
170
- # deleted as well or you will essentially have a dead reference. It's recommended to use the +#purge+ and +#purge_later+
272
+ def mirror_later #:nodoc:
273
+ ActiveStorage::MirrorJob.perform_later(key, checksum: checksum) if service.respond_to?(:mirror)
274
+ end
275
+
276
+ # Deletes the files on the service associated with the blob. This should only be done if the blob is going to be
277
+ # deleted as well or you will essentially have a dead reference. It's recommended to use #purge and #purge_later
171
278
  # methods in most circumstances.
172
279
  def delete
173
280
  service.delete(key)
174
281
  service.delete_prefixed("variants/#{key}/") if image?
175
282
  end
176
283
 
177
- # Deletes the file on the service and then destroys the blob record. This is the recommended way to dispose of unwanted
178
- # blobs. Note, though, that deleting the file off the service will initiate a HTTP connection to the service, which may
179
- # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use +#purge_later+ instead.
284
+ # Destroys the blob record and then deletes the file on the service. This is the recommended way to dispose of unwanted
285
+ # blobs. Note, though, that deleting the file off the service will initiate an HTTP connection to the service, which may
286
+ # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use #purge_later instead.
180
287
  def purge
181
288
  destroy
182
289
  delete
183
290
  rescue ActiveRecord::InvalidForeignKey
184
291
  end
185
292
 
186
- # Enqueues an ActiveStorage::PurgeJob job that'll call +purge+. This is the recommended way to purge blobs when the call
187
- # needs to be made from a transaction, a callback, or any other real-time scenario.
293
+ # Enqueues an ActiveStorage::PurgeJob to call #purge. This is the recommended way to purge blobs from a transaction,
294
+ # an Active Record callback, or in any other real-time scenario.
188
295
  def purge_later
189
296
  ActiveStorage::PurgeJob.perform_later(self)
190
297
  end
191
298
 
299
+ # Returns an instance of service, which can be configured globally or per attachment
300
+ def service
301
+ services.fetch(service_name)
302
+ end
303
+
192
304
  private
193
305
  def compute_checksum_in_chunks(io)
194
306
  Digest::MD5.new.tap do |checksum|
@@ -212,14 +324,8 @@ class ActiveStorage::Blob < ActiveRecord::Base
212
324
  ActiveStorage.content_types_allowed_inline.include?(content_type)
213
325
  end
214
326
 
215
- def content_type_for_service_url
216
- forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : content_type
217
- end
218
-
219
- def forced_disposition_for_service_url
220
- if forcibly_serve_as_binary? || !allowed_inline?
221
- :attachment
222
- end
327
+ def web_image?
328
+ ActiveStorage.web_image_content_types.include?(content_type)
223
329
  end
224
330
 
225
331
  def service_metadata
@@ -232,5 +338,9 @@ class ActiveStorage::Blob < ActiveRecord::Base
232
338
  end
233
339
  end
234
340
 
235
- ActiveSupport.run_load_hooks(:active_storage_blob, self)
341
+ def update_service_metadata
342
+ service.update_metadata key, **service_metadata if service_metadata.any?
343
+ end
236
344
  end
345
+
346
+ ActiveSupport.run_load_hooks :active_storage_blob, ActiveStorage::Blob