activestorage 5.2.7.1 → 6.1.4.6

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +225 -93
  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/base_controller.rb +14 -0
  12. data/app/controllers/active_storage/representations/proxy_controller.rb +13 -0
  13. data/app/controllers/active_storage/{representations_controller.rb → representations/redirect_controller.rb} +3 -5
  14. data/app/controllers/concerns/active_storage/file_server.rb +18 -0
  15. data/app/controllers/concerns/active_storage/set_blob.rb +1 -1
  16. data/app/controllers/concerns/active_storage/set_current.rb +15 -0
  17. data/app/controllers/concerns/active_storage/set_headers.rb +12 -0
  18. data/app/javascript/activestorage/blob_record.js +7 -2
  19. data/app/jobs/active_storage/analyze_job.rb +5 -0
  20. data/app/jobs/active_storage/base_job.rb +0 -1
  21. data/app/jobs/active_storage/mirror_job.rb +15 -0
  22. data/app/jobs/active_storage/purge_job.rb +3 -0
  23. data/app/models/active_storage/attachment.rb +35 -16
  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/blob.rb +186 -68
  28. data/app/models/active_storage/filename.rb +0 -6
  29. data/app/models/active_storage/preview.rb +37 -12
  30. data/app/models/active_storage/record.rb +7 -0
  31. data/app/models/active_storage/variant.rb +53 -67
  32. data/app/models/active_storage/variant_record.rb +8 -0
  33. data/app/models/active_storage/variant_with_record.rb +54 -0
  34. data/app/models/active_storage/variation.rb +30 -94
  35. data/config/routes.rb +66 -15
  36. data/db/migrate/20170806125915_create_active_storage_tables.rb +14 -5
  37. data/db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb +17 -0
  38. data/db/update_migrate/20191206030411_create_active_storage_variant_records.rb +11 -0
  39. data/lib/active_storage/analyzer/image_analyzer.rb +14 -4
  40. data/lib/active_storage/analyzer/null_analyzer.rb +4 -0
  41. data/lib/active_storage/analyzer/video_analyzer.rb +17 -8
  42. data/lib/active_storage/analyzer.rb +15 -4
  43. data/lib/active_storage/attached/changes/create_many.rb +47 -0
  44. data/lib/active_storage/attached/changes/create_one.rb +82 -0
  45. data/lib/active_storage/attached/changes/create_one_of_many.rb +10 -0
  46. data/lib/active_storage/attached/changes/delete_many.rb +27 -0
  47. data/lib/active_storage/attached/changes/delete_one.rb +19 -0
  48. data/lib/active_storage/attached/changes.rb +16 -0
  49. data/lib/active_storage/attached/many.rb +19 -12
  50. data/lib/active_storage/attached/model.rb +212 -0
  51. data/lib/active_storage/attached/one.rb +19 -21
  52. data/lib/active_storage/attached.rb +7 -22
  53. data/lib/active_storage/downloader.rb +43 -0
  54. data/lib/active_storage/engine.rb +60 -38
  55. data/lib/active_storage/errors.rb +25 -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/mupdf_previewer.rb +3 -3
  59. data/lib/active_storage/previewer/poppler_pdf_previewer.rb +3 -3
  60. data/lib/active_storage/previewer/video_previewer.rb +17 -10
  61. data/lib/active_storage/previewer.rb +34 -14
  62. data/lib/active_storage/reflection.rb +64 -0
  63. data/lib/active_storage/service/azure_storage_service.rb +65 -44
  64. data/lib/active_storage/service/configurator.rb +6 -2
  65. data/lib/active_storage/service/disk_service.rb +57 -44
  66. data/lib/active_storage/service/gcs_service.rb +68 -64
  67. data/lib/active_storage/service/mirror_service.rb +31 -7
  68. data/lib/active_storage/service/registry.rb +32 -0
  69. data/lib/active_storage/service/s3_service.rb +56 -24
  70. data/lib/active_storage/service.rb +44 -12
  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/active_storage.rb +31 -296
  74. data/lib/tasks/activestorage.rake +11 -0
  75. metadata +82 -16
  76. data/app/models/active_storage/filename/parameters.rb +0 -36
  77. data/lib/active_storage/attached/macros.rb +0 -110
  78. data/lib/active_storage/downloading.rb +0 -39
@@ -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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController #:nodoc:
4
+ include ActiveStorage::SetBlob
5
+
6
+ before_action :set_representation
7
+
8
+ private
9
+ def set_representation
10
+ @representation = @blob.representation(params[:variation_key]).processed
11
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
12
+ head :not_found
13
+ end
14
+ end
@@ -0,0 +1,13 @@
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::Representations::BaseController
5
+ include ActiveStorage::SetHeaders
6
+
7
+ def show
8
+ http_cache_forever public: true do
9
+ set_content_headers_from @representation.image
10
+ stream @representation
11
+ end
12
+ end
13
+ end
@@ -4,11 +4,9 @@
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
8
- include ActiveStorage::SetBlob
9
-
7
+ class ActiveStorage::Representations::RedirectController < ActiveStorage::Representations::BaseController
10
8
  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])
9
+ expires_in ActiveStorage.service_urls_expire_in
10
+ redirect_to @representation.url(disposition: params[:disposition])
13
11
  end
14
12
  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
@@ -29,12 +29,16 @@ module ActiveStorage::Blob::Analyzable
29
29
  update! metadata: metadata.merge(extract_metadata_via_analyzer)
30
30
  end
31
31
 
32
- # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze.
32
+ # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze, or calls #analyze inline based on analyzer class configuration.
33
33
  #
34
34
  # This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob
35
35
  # again (e.g. if you add a new analyzer or modify an existing one).
36
36
  def analyze_later
37
- ActiveStorage::AnalyzeJob.perform_later(self)
37
+ if analyzer_class.analyze_later?
38
+ ActiveStorage::AnalyzeJob.perform_later(self)
39
+ else
40
+ analyze
41
+ end
38
42
  end
39
43
 
40
44
  # Returns true if the blob has been analyzed.
@@ -2,9 +2,14 @@
2
2
 
3
3
  module ActiveStorage::Blob::Identifiable
4
4
  def identify
5
+ identify_without_saving
6
+ save!
7
+ end
8
+
9
+ def identify_without_saving
5
10
  unless identified?
6
- update! content_type: identify_content_type, identified: true
7
- update_service_metadata
11
+ self.content_type = identify_content_type
12
+ self.identified = true
8
13
  end
9
14
  end
10
15
 
@@ -24,8 +29,4 @@ module ActiveStorage::Blob::Identifiable
24
29
  ""
25
30
  end
26
31
  end
27
-
28
- def update_service_metadata
29
- service.update_metadata key, service_metadata if service_metadata.any?
30
- end
31
32
  end
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mini_mime"
4
+
3
5
  module ActiveStorage::Blob::Representable
4
6
  extend ActiveSupport::Concern
5
7
 
6
8
  included do
9
+ has_many :variant_records, class_name: "ActiveStorage::VariantRecord", dependent: false
10
+ before_destroy { variant_records.destroy_all if ActiveStorage.track_variants }
11
+
7
12
  has_one_attached :preview_image
8
13
  end
9
14
 
10
15
  # Returns an ActiveStorage::Variant instance with the set of +transformations+ provided. This is only relevant for image
11
16
  # files, and it allows any image to be transformed for size, colors, and the like. Example:
12
17
  #
13
- # avatar.variant(resize: "100x100").processed.service_url
18
+ # avatar.variant(resize_to_limit: [100, 100]).processed.url
14
19
  #
15
20
  # This will create and process a variant of the avatar blob that's constrained to a height and width of 100px.
16
21
  # Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
@@ -18,7 +23,7 @@ module ActiveStorage::Blob::Representable
18
23
  # Frequently, though, you don't actually want to transform the variant right away. But rather simply refer to a
19
24
  # specific variant that can be created by a controller on-demand. Like so:
20
25
  #
21
- # <%= image_tag Current.user.avatar.variant(resize: "100x100") %>
26
+ # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
22
27
  #
23
28
  # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
24
29
  # can then produce on-demand.
@@ -27,7 +32,7 @@ module ActiveStorage::Blob::Representable
27
32
  # variable, call ActiveStorage::Blob#variable?.
28
33
  def variant(transformations)
29
34
  if variable?
30
- ActiveStorage::Variant.new(self, transformations)
35
+ variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
31
36
  else
32
37
  raise ActiveStorage::InvariableError
33
38
  end
@@ -43,13 +48,13 @@ module ActiveStorage::Blob::Representable
43
48
  # from a non-image blob. Active Storage comes with built-in previewers for videos and PDF documents. The video previewer
44
49
  # extracts the first frame from a video and the PDF previewer extracts the first page from a PDF document.
45
50
  #
46
- # blob.preview(resize: "100x100").processed.service_url
51
+ # blob.preview(resize_to_limit: [100, 100]).processed.url
47
52
  #
48
53
  # Avoid processing previews synchronously in views. Instead, link to a controller action that processes them on demand.
49
54
  # Active Storage provides one, but you may want to create your own (for example, if you need authentication). Here’s
50
55
  # how to use the built-in version:
51
56
  #
52
- # <%= image_tag video.preview(resize: "100x100") %>
57
+ # <%= image_tag video.preview(resize_to_limit: [100, 100]) %>
53
58
  #
54
59
  # This method raises ActiveStorage::UnpreviewableError if no previewer accepts the receiving blob. To determine
55
60
  # whether a blob is accepted by any previewer, call ActiveStorage::Blob#previewable?.
@@ -69,7 +74,7 @@ module ActiveStorage::Blob::Representable
69
74
 
70
75
  # Returns an ActiveStorage::Preview for a previewable blob or an ActiveStorage::Variant for a variable image blob.
71
76
  #
72
- # blob.representation(resize: "100x100").processed.service_url
77
+ # blob.representation(resize_to_limit: [100, 100]).processed.url
73
78
  #
74
79
  # Raises ActiveStorage::UnrepresentableError if the receiving blob is neither variable nor previewable. Call
75
80
  # ActiveStorage::Blob#representable? to determine whether a blob is representable.
@@ -90,4 +95,29 @@ module ActiveStorage::Blob::Representable
90
95
  def representable?
91
96
  variable? || previewable?
92
97
  end
98
+
99
+ private
100
+ def default_variant_transformations
101
+ { format: default_variant_format }
102
+ end
103
+
104
+ def default_variant_format
105
+ if web_image?
106
+ format || :png
107
+ else
108
+ :png
109
+ end
110
+ end
111
+
112
+ def format
113
+ if filename.extension.present? && MiniMime.lookup_by_extension(filename.extension)&.content_type == content_type
114
+ filename.extension
115
+ else
116
+ MiniMime.lookup_by_content_type(content_type)&.extension
117
+ end
118
+ end
119
+
120
+ def variant_class
121
+ ActiveStorage.track_variants ? ActiveStorage::VariantWithRecord : ActiveStorage::Variant
122
+ end
93
123
  end