activestorage 6.1.7 → 7.1.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -276
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +29 -15
  5. data/app/assets/javascripts/activestorage.esm.js +848 -0
  6. data/app/assets/javascripts/activestorage.js +263 -376
  7. data/app/controllers/active_storage/base_controller.rb +0 -9
  8. data/app/controllers/active_storage/blobs/proxy_controller.rb +16 -4
  9. data/app/controllers/active_storage/blobs/redirect_controller.rb +6 -4
  10. data/app/controllers/active_storage/disk_controller.rb +5 -2
  11. data/app/controllers/active_storage/representations/base_controller.rb +5 -1
  12. data/app/controllers/active_storage/representations/proxy_controller.rb +8 -3
  13. data/app/controllers/active_storage/representations/redirect_controller.rb +6 -4
  14. data/app/controllers/concerns/active_storage/disable_session.rb +12 -0
  15. data/app/controllers/concerns/active_storage/file_server.rb +4 -1
  16. data/app/controllers/concerns/active_storage/set_blob.rb +6 -2
  17. data/app/controllers/concerns/active_storage/set_current.rb +3 -3
  18. data/app/controllers/concerns/active_storage/streaming.rb +66 -0
  19. data/app/javascript/activestorage/blob_record.js +4 -1
  20. data/app/javascript/activestorage/direct_upload.js +3 -2
  21. data/app/javascript/activestorage/index.js +3 -1
  22. data/app/javascript/activestorage/ujs.js +1 -1
  23. data/app/jobs/active_storage/analyze_job.rb +1 -1
  24. data/app/jobs/active_storage/mirror_job.rb +1 -1
  25. data/app/jobs/active_storage/purge_job.rb +1 -1
  26. data/app/jobs/active_storage/transform_job.rb +12 -0
  27. data/app/models/active_storage/attachment.rb +111 -4
  28. data/app/models/active_storage/blob/analyzable.rb +4 -3
  29. data/app/models/active_storage/blob/identifiable.rb +1 -0
  30. data/app/models/active_storage/blob/representable.rb +14 -8
  31. data/app/models/active_storage/blob.rb +93 -57
  32. data/app/models/active_storage/current.rb +2 -2
  33. data/app/models/active_storage/filename.rb +2 -0
  34. data/app/models/active_storage/named_variant.rb +21 -0
  35. data/app/models/active_storage/preview.rb +11 -7
  36. data/app/models/active_storage/record.rb +1 -1
  37. data/app/models/active_storage/variant.rb +10 -12
  38. data/app/models/active_storage/variant_record.rb +2 -0
  39. data/app/models/active_storage/variant_with_record.rb +28 -12
  40. data/app/models/active_storage/variation.rb +7 -5
  41. data/config/routes.rb +12 -10
  42. data/db/migrate/20170806125915_create_active_storage_tables.rb +15 -6
  43. data/db/update_migrate/20211119233751_remove_not_null_on_active_storage_blobs_checksum.rb +7 -0
  44. data/lib/active_storage/analyzer/audio_analyzer.rb +77 -0
  45. data/lib/active_storage/analyzer/image_analyzer/image_magick.rb +41 -0
  46. data/lib/active_storage/analyzer/image_analyzer/vips.rb +51 -0
  47. data/lib/active_storage/analyzer/image_analyzer.rb +4 -30
  48. data/lib/active_storage/analyzer/video_analyzer.rb +41 -17
  49. data/lib/active_storage/analyzer.rb +10 -4
  50. data/lib/active_storage/attached/changes/create_many.rb +14 -5
  51. data/lib/active_storage/attached/changes/create_one.rb +46 -4
  52. data/lib/active_storage/attached/changes/create_one_of_many.rb +1 -1
  53. data/lib/active_storage/attached/changes/delete_many.rb +1 -1
  54. data/lib/active_storage/attached/changes/delete_one.rb +1 -1
  55. data/lib/active_storage/attached/changes/detach_many.rb +18 -0
  56. data/lib/active_storage/attached/changes/detach_one.rb +24 -0
  57. data/lib/active_storage/attached/changes/purge_many.rb +27 -0
  58. data/lib/active_storage/attached/changes/purge_one.rb +27 -0
  59. data/lib/active_storage/attached/changes.rb +7 -1
  60. data/lib/active_storage/attached/many.rb +32 -19
  61. data/lib/active_storage/attached/model.rb +80 -29
  62. data/lib/active_storage/attached/one.rb +37 -31
  63. data/lib/active_storage/attached.rb +2 -0
  64. data/lib/active_storage/deprecator.rb +7 -0
  65. data/lib/active_storage/downloader.rb +4 -4
  66. data/lib/active_storage/engine.rb +55 -7
  67. data/lib/active_storage/fixture_set.rb +75 -0
  68. data/lib/active_storage/gem_version.rb +3 -3
  69. data/lib/active_storage/log_subscriber.rb +12 -0
  70. data/lib/active_storage/previewer.rb +12 -5
  71. data/lib/active_storage/reflection.rb +12 -2
  72. data/lib/active_storage/service/azure_storage_service.rb +30 -6
  73. data/lib/active_storage/service/configurator.rb +1 -1
  74. data/lib/active_storage/service/disk_service.rb +26 -19
  75. data/lib/active_storage/service/gcs_service.rb +100 -11
  76. data/lib/active_storage/service/mirror_service.rb +12 -7
  77. data/lib/active_storage/service/registry.rb +1 -1
  78. data/lib/active_storage/service/s3_service.rb +39 -15
  79. data/lib/active_storage/service.rb +17 -7
  80. data/lib/active_storage/transformers/image_processing_transformer.rb +1 -1
  81. data/lib/active_storage/transformers/transformer.rb +3 -1
  82. data/lib/active_storage/version.rb +1 -1
  83. data/lib/active_storage.rb +22 -2
  84. metadata +30 -30
  85. data/app/controllers/concerns/active_storage/set_headers.rb +0 -12
@@ -7,13 +7,4 @@ class ActiveStorage::BaseController < ActionController::Base
7
7
  protect_from_forgery with: :exception
8
8
 
9
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
19
10
  end
@@ -1,14 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Proxy files through application. This avoids having a redirect and makes files easier to cache.
4
+ #
5
+ # WARNING: All Active Storage controllers are publicly accessible by default. The
6
+ # generated URLs are hard to guess, but permanent by design. If your files
7
+ # require a higher level of protection consider implementing
8
+ # {Authenticated Controllers}[https://guides.rubyonrails.org/active_storage_overview.html#authenticated-controllers].
4
9
  class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
5
10
  include ActiveStorage::SetBlob
6
- include ActiveStorage::SetHeaders
11
+ include ActiveStorage::Streaming
12
+ include ActiveStorage::DisableSession
7
13
 
8
14
  def show
9
- http_cache_forever public: true do
10
- set_content_headers_from @blob
11
- stream @blob
15
+ if request.headers["Range"].present?
16
+ send_blob_byte_range_data @blob, request.headers["Range"]
17
+ else
18
+ http_cache_forever public: true do
19
+ response.headers["Accept-Ranges"] = "bytes"
20
+ response.headers["Content-Length"] = @blob.byte_size.to_s
21
+
22
+ send_blob_stream @blob, disposition: params[:disposition]
23
+ end
12
24
  end
13
25
  end
14
26
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Take a signed permanent reference for a blob and turn it into an expiring service URL for download.
4
- # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
5
- # security-through-obscurity factor of the signed blob references, you'll need to implement your own
6
- # authenticated redirection controller.
4
+ #
5
+ # WARNING: All Active Storage controllers are publicly accessible by default. The
6
+ # generated URLs are hard to guess, but permanent by design. If your files
7
+ # require a higher level of protection consider implementing
8
+ # {Authenticated Controllers}[https://guides.rubyonrails.org/active_storage_overview.html#authenticated-controllers].
7
9
  class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
8
10
  include ActiveStorage::SetBlob
9
11
 
10
12
  def show
11
13
  expires_in ActiveStorage.service_urls_expire_in
12
- redirect_to @blob.url(disposition: params[:disposition])
14
+ redirect_to @blob.url(disposition: params[:disposition]), allow_other_host: true
13
15
  end
14
16
  end
@@ -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
@@ -41,11 +42,13 @@ class ActiveStorage::DiskController < ActiveStorage::BaseController
41
42
  end
42
43
 
43
44
  def decode_verified_key
44
- ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
45
+ key = ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
46
+ key&.deep_symbolize_keys
45
47
  end
46
48
 
47
49
  def decode_verified_token
48
- ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
50
+ token = ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
51
+ token&.deep_symbolize_keys
49
52
  end
50
53
 
51
54
  def acceptable_content?(token)
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController #:nodoc:
3
+ class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController # :nodoc:
4
4
  include ActiveStorage::SetBlob
5
5
 
6
6
  before_action :set_representation
7
7
 
8
8
  private
9
+ def blob_scope
10
+ ActiveStorage::Blob.scope_for_strict_loading
11
+ end
12
+
9
13
  def set_representation
10
14
  @representation = @blob.representation(params[:variation_key]).processed
11
15
  rescue ActiveSupport::MessageVerifier::InvalidSignature
@@ -1,13 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Proxy files through application. This avoids having a redirect and makes files easier to cache.
4
+ #
5
+ # WARNING: All Active Storage controllers are publicly accessible by default. The
6
+ # generated URLs are hard to guess, but permanent by design. If your files
7
+ # require a higher level of protection consider implementing
8
+ # {Authenticated Controllers}[https://guides.rubyonrails.org/active_storage_overview.html#authenticated-controllers].
4
9
  class ActiveStorage::Representations::ProxyController < ActiveStorage::Representations::BaseController
5
- include ActiveStorage::SetHeaders
10
+ include ActiveStorage::Streaming
11
+ include ActiveStorage::DisableSession
6
12
 
7
13
  def show
8
14
  http_cache_forever public: true do
9
- set_content_headers_from @representation.image
10
- stream @representation
15
+ send_blob_stream @representation.image, disposition: params[:disposition]
11
16
  end
12
17
  end
13
18
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Take a signed permanent reference for a blob representation and turn it into an expiring service URL for download.
4
- # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
5
- # security-through-obscurity factor of the signed blob and variation reference, you'll need to implement your own
6
- # authenticated redirection controller.
4
+ #
5
+ # WARNING: All Active Storage controllers are publicly accessible by default. The
6
+ # generated URLs are hard to guess, but permanent by design. If your files
7
+ # require a higher level of protection consider implementing
8
+ # {Authenticated Controllers}[https://guides.rubyonrails.org/active_storage_overview.html#authenticated-controllers].
7
9
  class ActiveStorage::Representations::RedirectController < ActiveStorage::Representations::BaseController
8
10
  def show
9
11
  expires_in ActiveStorage.service_urls_expire_in
10
- redirect_to @representation.url(disposition: params[:disposition])
12
+ redirect_to @representation.url(disposition: params[:disposition]), allow_other_host: true
11
13
  end
12
14
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This concern disables the session in order to allow caching by default in some CDNs as CloudFlare.
4
+ module ActiveStorage::DisableSession
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action do
9
+ request.session_options[:skip] = true
10
+ end
11
+ end
12
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/hash/except"
4
+
3
5
  module ActiveStorage::FileServer # :nodoc:
4
6
  private
5
7
  def serve_file(path, content_type:, disposition:)
6
- Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
8
+ ::Rack::Files.new(nil).serving(request, path).tap do |(status, headers, body)|
7
9
  self.status = status
8
10
  self.response_body = body
9
11
 
@@ -11,6 +13,7 @@ module ActiveStorage::FileServer # :nodoc:
11
13
  response.headers[name] = value
12
14
  end
13
15
 
16
+ response.headers.except!("X-Cascade", "x-cascade") if status == 416
14
17
  response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
15
18
  response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
16
19
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module ActiveStorage::SetBlob #:nodoc:
3
+ module ActiveStorage::SetBlob # :nodoc:
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
@@ -9,8 +9,12 @@ 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 = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id])
13
13
  rescue ActiveSupport::MessageVerifier::InvalidSignature
14
14
  head :not_found
15
15
  end
16
+
17
+ def blob_scope
18
+ ActiveStorage::Blob
19
+ end
16
20
  end
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Sets the <tt>ActiveStorage::Current.host</tt> attribute, which the disk service uses to generate URLs.
3
+ # Sets the <tt>ActiveStorage::Current.url_options</tt> attribute, which the disk service uses to generate URLs.
4
4
  # Include this concern in custom controllers that call ActiveStorage::Blob#url,
5
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.
6
+ # generate URLs using the same host, protocol, and port as the current request.
7
7
  module ActiveStorage::SetCurrent
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  included do
11
11
  before_action do
12
- ActiveStorage::Current.host = request.base_url
12
+ ActiveStorage::Current.url_options = { protocol: request.protocol, host: request.host, port: request.port }
13
13
  end
14
14
  end
15
15
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module ActiveStorage::Streaming
6
+ extend ActiveSupport::Concern
7
+ DEFAULT_BLOB_STREAMING_DISPOSITION = "inline"
8
+
9
+ include ActionController::DataStreaming
10
+ include ActionController::Live
11
+
12
+ private
13
+ # Stream the blob in byte ranges specified through the header
14
+ def send_blob_byte_range_data(blob, range_header, disposition: nil)
15
+ ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
16
+
17
+ return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?)
18
+
19
+ if ranges.length == 1
20
+ range = ranges.first
21
+ content_type = blob.content_type_for_serving
22
+ data = blob.download_chunk(range)
23
+
24
+ response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{blob.byte_size}"
25
+ else
26
+ boundary = SecureRandom.hex
27
+ content_type = "multipart/byteranges; boundary=#{boundary}"
28
+ data = +""
29
+
30
+ ranges.compact.each do |range|
31
+ chunk = blob.download_chunk(range)
32
+
33
+ data << "\r\n--#{boundary}\r\n"
34
+ data << "Content-Type: #{blob.content_type_for_serving}\r\n"
35
+ data << "Content-Range: bytes #{range.begin}-#{range.end}/#{blob.byte_size}\r\n\r\n"
36
+ data << chunk
37
+ end
38
+
39
+ data << "\r\n--#{boundary}--\r\n"
40
+ end
41
+
42
+ response.headers["Accept-Ranges"] = "bytes"
43
+ response.headers["Content-Length"] = data.length.to_s
44
+
45
+ send_data(
46
+ data,
47
+ disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
48
+ filename: blob.filename.sanitized,
49
+ status: :partial_content,
50
+ type: content_type
51
+ )
52
+ end
53
+
54
+ # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+.
55
+ # The content type and filename is set directly from the +blob+.
56
+ def send_blob_stream(blob, disposition: nil) # :doc:
57
+ send_stream(
58
+ filename: blob.filename.sanitized,
59
+ disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
60
+ type: blob.content_type_for_serving) do |stream|
61
+ blob.download do |chunk|
62
+ stream.write chunk
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,7 +1,7 @@
1
1
  import { getMetaValue } from "./helpers"
2
2
 
3
3
  export class BlobRecord {
4
- constructor(file, checksum, url) {
4
+ constructor(file, checksum, url, customHeaders = {}) {
5
5
  this.file = file
6
6
 
7
7
  this.attributes = {
@@ -17,6 +17,9 @@ 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
+ Object.keys(customHeaders).forEach((headerKey) => {
21
+ this.xhr.setRequestHeader(headerKey, customHeaders[headerKey])
22
+ })
20
23
 
21
24
  const csrfToken = getMetaValue("csrf-token")
22
25
  if (csrfToken != undefined) {
@@ -5,11 +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, delegate, customHeaders = {}) {
9
9
  this.id = ++id
10
10
  this.file = file
11
11
  this.url = url
12
12
  this.delegate = delegate
13
+ this.customHeaders = customHeaders
13
14
  }
14
15
 
15
16
  create(callback) {
@@ -19,7 +20,7 @@ export class DirectUpload {
19
20
  return
20
21
  }
21
22
 
22
- const blob = new BlobRecord(this.file, checksum, this.url)
23
+ const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders)
23
24
  notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
24
25
 
25
26
  blob.create(error => {
@@ -1,6 +1,8 @@
1
1
  import { start } from "./ujs"
2
2
  import { DirectUpload } from "./direct_upload"
3
- export { start, DirectUpload }
3
+ import { DirectUploadController } from "./direct_upload_controller"
4
+ import { DirectUploadsController } from "./direct_uploads_controller"
5
+ export { start, DirectUpload, DirectUploadController, DirectUploadsController }
4
6
 
5
7
  function autostart() {
6
8
  if (window.ActiveStorage) {
@@ -9,7 +9,7 @@ export function start() {
9
9
  if (!started) {
10
10
  started = true
11
11
  document.addEventListener("click", didClick, true)
12
- document.addEventListener("submit", didSubmitForm)
12
+ document.addEventListener("submit", didSubmitForm, true)
13
13
  document.addEventListener("ajax:before", didSubmitRemoteElement)
14
14
  }
15
15
  }
@@ -5,7 +5,7 @@ class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob
5
5
  queue_as { ActiveStorage.queues[:analysis] }
6
6
 
7
7
  discard_on ActiveRecord::RecordNotFound
8
- retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
8
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
9
9
 
10
10
  def perform(blob)
11
11
  blob.analyze
@@ -7,7 +7,7 @@ class ActiveStorage::MirrorJob < ActiveStorage::BaseJob
7
7
  queue_as { ActiveStorage.queues[:mirror] }
8
8
 
9
9
  discard_on ActiveStorage::FileNotFoundError
10
- retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
10
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
11
11
 
12
12
  def perform(key, checksum:)
13
13
  ActiveStorage::Blob.service.try(:mirror, key, checksum: checksum)
@@ -5,7 +5,7 @@ class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
5
5
  queue_as { ActiveStorage.queues[:purge] }
6
6
 
7
7
  discard_on ActiveRecord::RecordNotFound
8
- retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer
8
+ retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :polynomially_longer
9
9
 
10
10
  def perform(blob)
11
11
  blob.purge
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveStorage::TransformJob < ActiveStorage::BaseJob
4
+ queue_as { ActiveStorage.queues[:transform] }
5
+
6
+ discard_on ActiveRecord::RecordNotFound
7
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
8
+
9
+ def perform(blob, transformations)
10
+ blob.variant(transformations).processed
11
+ end
12
+ end
@@ -2,28 +2,55 @@
2
2
 
3
3
  require "active_support/core_ext/module/delegation"
4
4
 
5
+ # = Active Storage \Attachment
6
+ #
5
7
  # Attachments associate records with blobs. Usually that's a one record-many blobs relationship,
6
8
  # but it is possible to associate many different records with the same blob. A foreign-key constraint
7
9
  # on the attachments table prevents blobs from being purged if they’re still attached to any records.
8
10
  #
9
- # Attachments also have access to all methods from {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob].
11
+ # Attachments also have access to all methods from ActiveStorage::Blob.
12
+ #
13
+ # If you wish to preload attachments or blobs, you can use these scopes:
14
+ #
15
+ # # preloads attachments, their corresponding blobs, and variant records (if using `ActiveStorage.track_variants`)
16
+ # User.all.with_attached_avatars
17
+ #
18
+ # # preloads blobs and variant records (if using `ActiveStorage.track_variants`)
19
+ # User.first.avatars.with_all_variant_records
10
20
  class ActiveStorage::Attachment < ActiveStorage::Record
11
21
  self.table_name = "active_storage_attachments"
12
22
 
23
+ ##
24
+ # :method:
25
+ #
26
+ # Returns the associated record.
13
27
  belongs_to :record, polymorphic: true, touch: true
28
+
29
+ ##
30
+ # :method:
31
+ #
32
+ # Returns the associated ActiveStorage::Blob.
14
33
  belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true
15
34
 
16
35
  delegate_missing_to :blob
17
36
  delegate :signed_id, to: :blob
18
37
 
19
- after_create_commit :mirror_blob_later, :analyze_blob_later
38
+ after_create_commit :mirror_blob_later, :analyze_blob_later, :transform_variants_later
20
39
  after_destroy_commit :purge_dependent_blob_later
21
40
 
41
+ ##
42
+ # :singleton-method:
43
+ #
44
+ # Eager load all variant records on an attachment at once.
45
+ #
46
+ # User.first.avatars.with_all_variant_records
47
+ scope :with_all_variant_records, -> { includes(blob: { variant_records: { image_attachment: :blob } }) }
48
+
22
49
  # Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge].
23
50
  def purge
24
51
  transaction do
25
52
  delete
26
- record&.touch
53
+ record.touch if record&.persisted?
27
54
  end
28
55
  blob&.purge
29
56
  end
@@ -32,11 +59,68 @@ class ActiveStorage::Attachment < ActiveStorage::Record
32
59
  def purge_later
33
60
  transaction do
34
61
  delete
35
- record&.touch
62
+ record.touch if record&.persisted?
36
63
  end
37
64
  blob&.purge_later
38
65
  end
39
66
 
67
+ # Returns an ActiveStorage::Variant or ActiveStorage::VariantWithRecord
68
+ # instance for the attachment with the set of +transformations+ provided.
69
+ # Example:
70
+ #
71
+ # avatar.variant(resize_to_limit: [100, 100]).processed.url
72
+ #
73
+ # or if you are using pre-defined variants:
74
+ #
75
+ # avatar.variant(:thumb).processed.url
76
+ #
77
+ # See ActiveStorage::Blob::Representable#variant for more information.
78
+ #
79
+ # Raises an +ArgumentError+ if +transformations+ is a +Symbol+ which is an
80
+ # unknown pre-defined variant of the attachment.
81
+ def variant(transformations)
82
+ transformations = transformations_by_name(transformations)
83
+ blob.variant(transformations)
84
+ end
85
+
86
+ # Returns an ActiveStorage::Preview instance for the attachment with the set
87
+ # of +transformations+ provided.
88
+ # Example:
89
+ #
90
+ # video.preview(resize_to_limit: [100, 100]).processed.url
91
+ #
92
+ # or if you are using pre-defined variants:
93
+ #
94
+ # video.preview(:thumb).processed.url
95
+ #
96
+ # See ActiveStorage::Blob::Representable#preview for more information.
97
+ #
98
+ # Raises an +ArgumentError+ if +transformations+ is a +Symbol+ which is an
99
+ # unknown pre-defined variant of the attachment.
100
+ def preview(transformations)
101
+ transformations = transformations_by_name(transformations)
102
+ blob.preview(transformations)
103
+ end
104
+
105
+ # Returns an ActiveStorage::Preview or an ActiveStorage::Variant for the
106
+ # attachment with set of +transformations+ provided.
107
+ # Example:
108
+ #
109
+ # avatar.representation(resize_to_limit: [100, 100]).processed.url
110
+ #
111
+ # or if you are using pre-defined variants:
112
+ #
113
+ # avatar.representation(:thumb).processed.url
114
+ #
115
+ # See ActiveStorage::Blob::Representable#representation for more information.
116
+ #
117
+ # Raises an +ArgumentError+ if +transformations+ is a +Symbol+ which is an
118
+ # unknown pre-defined variant of the attachment.
119
+ def representation(transformations)
120
+ transformations = transformations_by_name(transformations)
121
+ blob.representation(transformations)
122
+ end
123
+
40
124
  private
41
125
  def analyze_blob_later
42
126
  blob.analyze_later unless blob.analyzed?
@@ -46,6 +130,12 @@ class ActiveStorage::Attachment < ActiveStorage::Record
46
130
  blob.mirror_later
47
131
  end
48
132
 
133
+ def transform_variants_later
134
+ named_variants.each do |_name, named_variant|
135
+ blob.preprocessed(named_variant.transformations) if named_variant.preprocessed?(record)
136
+ end
137
+ end
138
+
49
139
  def purge_dependent_blob_later
50
140
  blob&.purge_later if dependent == :purge_later
51
141
  end
@@ -53,6 +143,23 @@ class ActiveStorage::Attachment < ActiveStorage::Record
53
143
  def dependent
54
144
  record.attachment_reflections[name]&.options&.fetch(:dependent, nil)
55
145
  end
146
+
147
+ def named_variants
148
+ record.attachment_reflections[name]&.named_variants
149
+ end
150
+
151
+ def transformations_by_name(transformations)
152
+ case transformations
153
+ when Symbol
154
+ variant_name = transformations
155
+ named_variants.fetch(variant_name) do
156
+ record_model_name = record.to_model.model_name.name
157
+ raise ArgumentError, "Cannot find variant :#{variant_name} for #{record_model_name}##{name}"
158
+ end.transformations
159
+ else
160
+ transformations
161
+ end
162
+ end
56
163
  end
57
164
 
58
165
  ActiveSupport.run_load_hooks :active_storage_attachment, ActiveStorage::Attachment
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_storage/analyzer/null_analyzer"
4
4
 
5
+ # = Active Storage \Blob \Analyzable
5
6
  module ActiveStorage::Blob::Analyzable
6
7
  # Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes
7
8
  # with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and
@@ -12,7 +13,7 @@ module ActiveStorage::Blob::Analyzable
12
13
  # first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no
13
14
  # metadata is extracted from it.
14
15
  #
15
- # In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+
16
+ # In a \Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+
16
17
  # in an initializer:
17
18
  #
18
19
  # # Add a custom analyzer for Microsoft Office documents:
@@ -21,9 +22,9 @@ module ActiveStorage::Blob::Analyzable
21
22
  # # Remove the built-in video analyzer:
22
23
  # Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
23
24
  #
24
- # Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead.
25
+ # Outside of a \Rails application, manipulate +ActiveStorage.analyzers+ instead.
25
26
  #
26
- # You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously
27
+ # You won't ordinarily need to call this method from a \Rails application. New blobs are automatically and asynchronously
27
28
  # analyzed via #analyze_later when they're attached for the first time.
28
29
  def analyze
29
30
  update! metadata: metadata.merge(extract_metadata_via_analyzer)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # = Active Storage \Blob \Identifiable
3
4
  module ActiveStorage::Blob::Identifiable
4
5
  def identify
5
6
  identify_without_saving