aboutsummaryrefslogtreecommitdiffstats
path: root/activestorage/app/models/active_storage
diff options
context:
space:
mode:
Diffstat (limited to 'activestorage/app/models/active_storage')
-rw-r--r--activestorage/app/models/active_storage/attachment.rb33
-rw-r--r--activestorage/app/models/active_storage/blob.rb263
-rw-r--r--activestorage/app/models/active_storage/blob/analyzable.rb57
-rw-r--r--activestorage/app/models/active_storage/blob/identifiable.rb31
-rw-r--r--activestorage/app/models/active_storage/blob/representable.rb93
-rw-r--r--activestorage/app/models/active_storage/current.rb5
-rw-r--r--activestorage/app/models/active_storage/filename.rb12
-rw-r--r--activestorage/app/models/active_storage/filename/parameters.rb36
-rw-r--r--activestorage/app/models/active_storage/preview.rb9
-rw-r--r--activestorage/app/models/active_storage/variant.rb85
-rw-r--r--activestorage/app/models/active_storage/variation.rb41
11 files changed, 418 insertions, 247 deletions
diff --git a/activestorage/app/models/active_storage/attachment.rb b/activestorage/app/models/active_storage/attachment.rb
index 9f61a5dbf3..13758d9179 100644
--- a/activestorage/app/models/active_storage/attachment.rb
+++ b/activestorage/app/models/active_storage/attachment.rb
@@ -3,9 +3,8 @@
require "active_support/core_ext/module/delegation"
# Attachments associate records with blobs. Usually that's a one record-many blobs relationship,
-# but it is possible to associate many different records with the same blob. If you're doing that,
-# you'll want to declare with <tt>has_one/many_attached :thingy, dependent: false</tt>, so that destroying
-# any one record won't destroy the blob as well. (Then you'll need to do your own garbage collecting, though).
+# but it is possible to associate many different records with the same blob. A foreign-key constraint
+# on the attachments table prevents blobs from being purged if they’re still attached to any records.
class ActiveStorage::Attachment < ActiveRecord::Base
self.table_name = "active_storage_attachments"
@@ -14,22 +13,36 @@ class ActiveStorage::Attachment < ActiveRecord::Base
delegate_missing_to :blob
- after_create_commit :analyze_blob_later
+ after_create_commit :analyze_blob_later, :identify_blob
+ after_destroy_commit :purge_dependent_blob_later
- # Synchronously purges the blob (deletes it from the configured service) and destroys the attachment.
+ # Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge].
def purge
- blob.purge
- destroy
+ delete
+ blob&.purge
end
- # Destroys the attachment and asynchronously purges the blob (deletes it from the configured service).
+ # Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob.
def purge_later
- blob.purge_later
- destroy
+ delete
+ blob&.purge_later
end
private
+ def identify_blob
+ blob.identify
+ end
+
def analyze_blob_later
blob.analyze_later unless blob.analyzed?
end
+
+ def purge_dependent_blob_later
+ blob&.purge_later if dependent == :purge_later
+ end
+
+
+ def dependent
+ record.attachment_reflections[name]&.options[:dependent]
+ end
end
diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb
index 3b48ee72af..6ca7d49bc1 100644
--- a/activestorage/app/models/active_storage/blob.rb
+++ b/activestorage/app/models/active_storage/blob.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require "active_storage/analyzer/null_analyzer"
+require "active_storage/downloader"
# A blob is a record that contains the metadata about a file and a key for where that file resides on the service.
# Blobs can be created in two ways:
@@ -16,23 +16,31 @@ require "active_storage/analyzer/null_analyzer"
# update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file.
# If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one.
class ActiveStorage::Blob < ActiveRecord::Base
- class InvariableError < StandardError; end
- class UnpreviewableError < StandardError; end
- class UnrepresentableError < StandardError; end
+ require_dependency "active_storage/blob/analyzable"
+ require_dependency "active_storage/blob/identifiable"
+ require_dependency "active_storage/blob/representable"
+
+ include Analyzable
+ include Identifiable
+ include Representable
self.table_name = "active_storage_blobs"
has_secure_token :key
- store :metadata, accessors: [ :analyzed ], coder: JSON
+ store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
class_attribute :service
has_many :attachments
- has_one_attached :preview_image
+ scope :unattached, -> { left_joins(:attachments).where(ActiveStorage::Attachment.table_name => { blob_id: nil }) }
+
+ before_destroy(prepend: true) do
+ raise ActiveRecord::InvalidForeignKey if attachments.exists?
+ end
class << self
- # You can used the signed ID of a blob to refer to it on the client side without fear of tampering.
+ # You can use the signed ID of a blob to refer to it on the client side without fear of tampering.
# This is particularly helpful for direct uploads where the client-side needs to refer to the blob
# that was created ahead of the upload itself on form submission.
#
@@ -42,21 +50,25 @@ class ActiveStorage::Blob < ActiveRecord::Base
end
# Returns a new, unsaved blob instance after the +io+ has been uploaded to the service.
- def build_after_upload(io:, filename:, content_type: nil, metadata: nil)
- new.tap do |blob|
- blob.filename = filename
- blob.content_type = content_type
- blob.metadata = metadata
+ # When providing a content type, pass <tt>identify: false</tt> to bypass automatic content type inference.
+ def build_after_upload(io:, filename:, content_type: nil, metadata: nil, identify: true)
+ new(filename: filename, content_type: content_type, metadata: metadata).tap do |blob|
+ blob.upload(io, identify: identify)
+ end
+ end
- blob.upload io
+ def build_after_unfurling(io:, filename:, content_type: nil, metadata: nil, identify: true) #:nodoc:
+ new(filename: filename, content_type: content_type, metadata: metadata).tap do |blob|
+ blob.unfurl(io, identify: identify)
end
end
# Returns a saved blob instance after the +io+ has been uploaded to the service. Note, the blob is first built,
# then the +io+ is uploaded, then the blob is saved. This is done this way to avoid uploading (which may take
# time), while having an open database transaction.
- def create_after_upload!(io:, filename:, content_type: nil, metadata: nil)
- build_after_upload(io: io, filename: filename, content_type: content_type, metadata: metadata).tap(&:save!)
+ # When providing a content type, pass <tt>identify: false</tt> to bypass automatic content type inference.
+ def create_after_upload!(io:, filename:, content_type: nil, metadata: nil, identify: true)
+ build_after_upload(io: io, filename: filename, content_type: content_type, metadata: metadata, identify: identify).tap(&:save!)
end
# Returns a saved blob _without_ uploading a file to the service. This blob will point to a key where there is
@@ -67,8 +79,16 @@ class ActiveStorage::Blob < ActiveRecord::Base
def create_before_direct_upload!(filename:, byte_size:, checksum:, content_type: nil, metadata: nil)
create! filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata
end
- end
+ # To prevent problems with case-insensitive filesystems, especially in combination
+ # with databases which treat indices as case-sensitive, all blob keys generated are going
+ # to only contain the base-36 character alphabet and will therefore be lowercase. To maintain
+ # the same or higher amount of entropy as in the base-58 encoding used by `has_secure_token`
+ # the number of bytes used is increased to 28 from the standard 24
+ def generate_unique_secure_token
+ SecureRandom.base36(28)
+ end
+ end
# Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
# It uses the framework-wide verifier on <tt>ActiveStorage.verifier</tt>, but with a dedicated purpose.
@@ -76,9 +96,10 @@ class ActiveStorage::Blob < ActiveRecord::Base
ActiveStorage.verifier.generate(id, purpose: :blob_id)
end
- # Returns the key pointing to the file on the service that's associated with this blob. The key is in the
- # standard secure-token format from Rails. So it'll look like: XTAPjJCJiuDrLk3TmwyJGpUo. This key is not intended
- # to be revealed directly to the user. Always refer to blobs using the signed_id or a verified form of the key.
+ # Returns the key pointing to the file on the service that's associated with this blob. The key is the
+ # secure-token format from Rails in lower case. So it'll look like: xtapjjcjiudrlk3tmwyjgpuobabd.
+ # This key is not intended to be revealed directly to the user.
+ # Always refer to blobs using the signed_id or a verified form of the key.
def key
# We can't wait until the record is first saved to have a key for it
self[:key] ||= self.class.generate_unique_secure_token
@@ -112,102 +133,20 @@ class ActiveStorage::Blob < ActiveRecord::Base
end
- # Returns an ActiveStorage::Variant instance with the set of +transformations+ provided. This is only relevant for image
- # files, and it allows any image to be transformed for size, colors, and the like. Example:
- #
- # avatar.variant(resize: "100x100").processed.service_url
- #
- # This will create and process a variant of the avatar blob that's constrained to a height and width of 100px.
- # Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
- #
- # Frequently, though, you don't actually want to transform the variant right away. But rather simply refer to a
- # specific variant that can be created by a controller on-demand. Like so:
- #
- # <%= image_tag Current.user.avatar.variant(resize: "100x100") %>
- #
- # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::VariantsController
- # can then produce on-demand.
- #
- # Raises ActiveStorage::Blob::InvariableError if ImageMagick cannot transform the blob. To determine whether a blob is
- # variable, call ActiveStorage::Blob#previewable?.
- def variant(transformations)
- if variable?
- ActiveStorage::Variant.new(self, ActiveStorage::Variation.wrap(transformations))
- else
- raise InvariableError
- end
- end
-
- # Returns true if ImageMagick can transform the blob (its content type is in +ActiveStorage.variable_content_types+).
- def variable?
- ActiveStorage.variable_content_types.include?(content_type)
- end
-
-
- # Returns an ActiveStorage::Preview instance with the set of +transformations+ provided. A preview is an image generated
- # from a non-image blob. Active Storage comes with built-in previewers for videos and PDF documents. The video previewer
- # extracts the first frame from a video and the PDF previewer extracts the first page from a PDF document.
- #
- # blob.preview(resize: "100x100").processed.service_url
- #
- # Avoid processing previews synchronously in views. Instead, link to a controller action that processes them on demand.
- # Active Storage provides one, but you may want to create your own (for example, if you need authentication). Here’s
- # how to use the built-in version:
- #
- # <%= image_tag video.preview(resize: "100x100") %>
- #
- # This method raises ActiveStorage::Blob::UnpreviewableError if no previewer accepts the receiving blob. To determine
- # whether a blob is accepted by any previewer, call ActiveStorage::Blob#previewable?.
- def preview(transformations)
- if previewable?
- ActiveStorage::Preview.new(self, ActiveStorage::Variation.wrap(transformations))
- else
- raise UnpreviewableError
- end
- end
-
- # Returns true if any registered previewer accepts the blob. By default, this will return true for videos and PDF documents.
- def previewable?
- ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
- end
-
-
- # Returns an ActiveStorage::Preview for a previewable blob or an ActiveStorage::Variant for a variable image blob.
- #
- # blob.representation(resize: "100x100").processed.service_url
- #
- # Raises ActiveStorage::Blob::UnrepresentableError if the receiving blob is neither variable nor previewable. Call
- # ActiveStorage::Blob#representable? to determine whether a blob is representable.
- #
- # See ActiveStorage::Blob#preview and ActiveStorage::Blob#variant for more information.
- def representation(transformations)
- case
- when previewable?
- preview transformations
- when variable?
- variant transformations
- else
- raise UnrepresentableError
- end
- end
-
- # Returns true if the blob is variable or previewable.
- def representable?
- variable? || previewable?
- end
-
-
# Returns the URL of the blob on the service. This URL is intended to be short-lived for security and not used directly
# with users. Instead, the +service_url+ should only be exposed as a redirect from a stable, possibly authenticated URL.
# Hiding the +service_url+ behind a redirect also gives you the power to change services without updating all URLs. And
# it allows permanent URLs that redirect to the +service_url+ to be cached in the view.
- def service_url(expires_in: service.url_expires_in, disposition: "inline")
- service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
+ def service_url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
+ filename = ActiveStorage::Filename.wrap(filename || self.filename)
+
+ service.url key, expires_in: expires_in, filename: filename, content_type: content_type_for_service_url,
+ disposition: forced_disposition_for_service_url || disposition, **options
end
# Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
# short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
- def service_url_for_direct_upload(expires_in: service.url_expires_in)
+ def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
end
@@ -216,21 +155,32 @@ class ActiveStorage::Blob < ActiveRecord::Base
service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
end
+
# Uploads the +io+ to the service on the +key+ for this blob. Blobs are intended to be immutable, so you shouldn't be
# using this method after a file has already been uploaded to fit with a blob. If you want to create a derivative blob,
# you should instead simply create a new blob based on the old one.
#
# Prior to uploading, we compute the checksum, which is sent to the service for transit integrity validation. If the
# checksum does not match what the service receives, an exception will be raised. We also measure the size of the +io+
- # and store that in +byte_size+ on the blob record.
+ # and store that in +byte_size+ on the blob record. The content type is automatically extracted from the +io+ unless
+ # you specify a +content_type+ and pass +identify+ as false.
#
# Normally, you do not have to call this method directly at all. Use the factory class methods of +build_after_upload+
# and +create_after_upload!+.
- def upload(io)
- self.checksum = compute_checksum_in_chunks(io)
- self.byte_size = io.size
+ def upload(io, identify: true)
+ unfurl io, identify: identify
+ upload_without_unfurling io
+ end
- service.upload(key, io, checksum: checksum)
+ def unfurl(io, identify: true) #:nodoc:
+ self.checksum = compute_checksum_in_chunks(io)
+ self.content_type = extract_content_type(io) if content_type.nil? || identify
+ self.byte_size = io.size
+ self.identified = true
+ end
+
+ def upload_without_unfurling(io) #:nodoc:
+ service.upload key, io, checksum: checksum, **service_metadata
end
# Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned.
@@ -239,49 +189,26 @@ class ActiveStorage::Blob < ActiveRecord::Base
service.download key, &block
end
-
- # Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes
- # with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and
- # ActiveStorage::Analyzer::VideoAnalyzer for information about the specific attributes they extract and the third-party
- # libraries they require.
- #
- # To choose the analyzer for a blob, Active Storage calls +accept?+ on each registered analyzer in order. It uses the
- # first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no
- # metadata is extracted from it.
+ # Downloads the blob to a tempfile on disk. Yields the tempfile.
#
- # In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+
- # in an initializer:
+ # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob.
#
- # # Add a custom analyzer for Microsoft Office documents:
- # Rails.application.config.active_storage.analyzers.append DOCXAnalyzer
+ # By default, the tempfile is created in <tt>Dir.tmpdir</tt>. Pass +tempdir:+ to create it in a different directory:
#
- # # Remove the built-in video analyzer:
- # Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
+ # blob.open(tempdir: "/path/to/tmp") do |file|
+ # # ...
+ # end
#
- # Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead.
+ # The tempfile is automatically closed and unlinked after the given block is executed.
#
- # You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously
- # analyzed via #analyze_later when they're attached for the first time.
- def analyze
- update! metadata: metadata.merge(extract_metadata_via_analyzer)
+ # Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum.
+ def open(tempdir: nil, &block)
+ ActiveStorage::Downloader.new(self, tempdir: tempdir).download_blob_to_tempfile(&block)
end
- # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze.
- #
- # This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob
- # again (e.g. if you add a new analyzer or modify an existing one).
- def analyze_later
- ActiveStorage::AnalyzeJob.perform_later(self)
- end
-
- # Returns true if the blob has been analyzed.
- def analyzed?
- analyzed
- end
-
- # Deletes the file on the service that's associated with this blob. This should only be done if the blob is going to be
- # deleted as well or you will essentially have a dead reference. It's recommended to use the +#purge+ and +#purge_later+
+ # Deletes the files on the service associated with the blob. This should only be done if the blob is going to be
+ # deleted as well or you will essentially have a dead reference. It's recommended to use #purge and #purge_later
# methods in most circumstances.
def delete
service.delete(key)
@@ -290,14 +217,15 @@ class ActiveStorage::Blob < ActiveRecord::Base
# Deletes the file on the service and then destroys the blob record. This is the recommended way to dispose of unwanted
# blobs. Note, though, that deleting the file off the service will initiate a HTTP connection to the service, which may
- # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use +#purge_later+ instead.
+ # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use #purge_later instead.
def purge
- delete
destroy
+ delete
+ rescue ActiveRecord::InvalidForeignKey
end
- # Enqueues an ActiveStorage::PurgeJob job that'll call +purge+. This is the recommended way to purge blobs when the call
- # needs to be made from a transaction, a callback, or any other real-time scenario.
+ # Enqueues an ActiveStorage::PurgeJob to call #purge. This is the recommended way to purge blobs from a transaction,
+ # an Active Record callback, or in any other real-time scenario.
def purge_later
ActiveStorage::PurgeJob.perform_later(self)
end
@@ -313,16 +241,37 @@ class ActiveStorage::Blob < ActiveRecord::Base
end.base64digest
end
+ def extract_content_type(io)
+ Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type
+ end
- def extract_metadata_via_analyzer
- analyzer.metadata.merge(analyzed: true)
+ def forcibly_serve_as_binary?
+ ActiveStorage.content_types_to_serve_as_binary.include?(content_type)
end
- def analyzer
- analyzer_class.new(self)
+ def allowed_inline?
+ ActiveStorage.content_types_allowed_inline.include?(content_type)
end
- def analyzer_class
- ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer
+ def content_type_for_service_url
+ forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : content_type
end
+
+ def forced_disposition_for_service_url
+ if forcibly_serve_as_binary? || !allowed_inline?
+ :attachment
+ end
+ end
+
+ def service_metadata
+ if forcibly_serve_as_binary?
+ { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
+ elsif !allowed_inline?
+ { content_type: content_type, disposition: :attachment, filename: filename }
+ else
+ { content_type: content_type }
+ end
+ end
+
+ ActiveSupport.run_load_hooks(:active_storage_blob, self)
end
diff --git a/activestorage/app/models/active_storage/blob/analyzable.rb b/activestorage/app/models/active_storage/blob/analyzable.rb
new file mode 100644
index 0000000000..5bda6e6d73
--- /dev/null
+++ b/activestorage/app/models/active_storage/blob/analyzable.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "active_storage/analyzer/null_analyzer"
+
+module ActiveStorage::Blob::Analyzable
+ # Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes
+ # with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and
+ # ActiveStorage::Analyzer::VideoAnalyzer for information about the specific attributes they extract and the third-party
+ # libraries they require.
+ #
+ # To choose the analyzer for a blob, Active Storage calls +accept?+ on each registered analyzer in order. It uses the
+ # first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no
+ # metadata is extracted from it.
+ #
+ # In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+
+ # in an initializer:
+ #
+ # # Add a custom analyzer for Microsoft Office documents:
+ # Rails.application.config.active_storage.analyzers.append DOCXAnalyzer
+ #
+ # # Remove the built-in video analyzer:
+ # Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
+ #
+ # Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead.
+ #
+ # You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously
+ # analyzed via #analyze_later when they're attached for the first time.
+ def analyze
+ update! metadata: metadata.merge(extract_metadata_via_analyzer)
+ end
+
+ # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze.
+ #
+ # This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob
+ # again (e.g. if you add a new analyzer or modify an existing one).
+ def analyze_later
+ ActiveStorage::AnalyzeJob.perform_later(self)
+ end
+
+ # Returns true if the blob has been analyzed.
+ def analyzed?
+ analyzed
+ end
+
+ private
+ def extract_metadata_via_analyzer
+ analyzer.metadata.merge(analyzed: true)
+ end
+
+ def analyzer
+ analyzer_class.new(self)
+ end
+
+ def analyzer_class
+ ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer
+ end
+end
diff --git a/activestorage/app/models/active_storage/blob/identifiable.rb b/activestorage/app/models/active_storage/blob/identifiable.rb
new file mode 100644
index 0000000000..924bd06131
--- /dev/null
+++ b/activestorage/app/models/active_storage/blob/identifiable.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module ActiveStorage::Blob::Identifiable
+ def identify
+ unless identified?
+ update! content_type: identify_content_type, identified: true
+ update_service_metadata
+ end
+ end
+
+ def identified?
+ identified
+ end
+
+ private
+ def identify_content_type
+ Marcel::MimeType.for download_identifiable_chunk, name: filename.to_s, declared_type: content_type
+ end
+
+ def download_identifiable_chunk
+ if byte_size.positive?
+ service.download_chunk key, 0...4.kilobytes
+ else
+ ""
+ end
+ end
+
+ def update_service_metadata
+ service.update_metadata key, service_metadata if service_metadata.any?
+ end
+end
diff --git a/activestorage/app/models/active_storage/blob/representable.rb b/activestorage/app/models/active_storage/blob/representable.rb
new file mode 100644
index 0000000000..32e8fcefdf
--- /dev/null
+++ b/activestorage/app/models/active_storage/blob/representable.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module ActiveStorage::Blob::Representable
+ extend ActiveSupport::Concern
+
+ included do
+ has_one_attached :preview_image
+ end
+
+ # Returns an ActiveStorage::Variant instance with the set of +transformations+ provided. This is only relevant for image
+ # files, and it allows any image to be transformed for size, colors, and the like. Example:
+ #
+ # avatar.variant(resize_to_limit: [100, 100]).processed.service_url
+ #
+ # This will create and process a variant of the avatar blob that's constrained to a height and width of 100px.
+ # Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
+ #
+ # Frequently, though, you don't actually want to transform the variant right away. But rather simply refer to a
+ # specific variant that can be created by a controller on-demand. Like so:
+ #
+ # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
+ #
+ # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
+ # can then produce on-demand.
+ #
+ # Raises ActiveStorage::InvariableError if ImageMagick cannot transform the blob. To determine whether a blob is
+ # variable, call ActiveStorage::Blob#variable?.
+ def variant(transformations)
+ if variable?
+ ActiveStorage::Variant.new(self, transformations)
+ else
+ raise ActiveStorage::InvariableError
+ end
+ end
+
+ # Returns true if ImageMagick can transform the blob (its content type is in +ActiveStorage.variable_content_types+).
+ def variable?
+ ActiveStorage.variable_content_types.include?(content_type)
+ end
+
+
+ # Returns an ActiveStorage::Preview instance with the set of +transformations+ provided. A preview is an image generated
+ # from a non-image blob. Active Storage comes with built-in previewers for videos and PDF documents. The video previewer
+ # extracts the first frame from a video and the PDF previewer extracts the first page from a PDF document.
+ #
+ # blob.preview(resize_to_limit: [100, 100]).processed.service_url
+ #
+ # Avoid processing previews synchronously in views. Instead, link to a controller action that processes them on demand.
+ # Active Storage provides one, but you may want to create your own (for example, if you need authentication). Here’s
+ # how to use the built-in version:
+ #
+ # <%= image_tag video.preview(resize_to_limit: [100, 100]) %>
+ #
+ # This method raises ActiveStorage::UnpreviewableError if no previewer accepts the receiving blob. To determine
+ # whether a blob is accepted by any previewer, call ActiveStorage::Blob#previewable?.
+ def preview(transformations)
+ if previewable?
+ ActiveStorage::Preview.new(self, transformations)
+ else
+ raise ActiveStorage::UnpreviewableError
+ end
+ end
+
+ # Returns true if any registered previewer accepts the blob. By default, this will return true for videos and PDF documents.
+ def previewable?
+ ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
+ end
+
+
+ # Returns an ActiveStorage::Preview for a previewable blob or an ActiveStorage::Variant for a variable image blob.
+ #
+ # blob.representation(resize_to_limit: [100, 100]).processed.service_url
+ #
+ # Raises ActiveStorage::UnrepresentableError if the receiving blob is neither variable nor previewable. Call
+ # ActiveStorage::Blob#representable? to determine whether a blob is representable.
+ #
+ # See ActiveStorage::Blob#preview and ActiveStorage::Blob#variant for more information.
+ def representation(transformations)
+ case
+ when previewable?
+ preview transformations
+ when variable?
+ variant transformations
+ else
+ raise ActiveStorage::UnrepresentableError
+ end
+ end
+
+ # Returns true if the blob is variable or previewable.
+ def representable?
+ variable? || previewable?
+ end
+end
diff --git a/activestorage/app/models/active_storage/current.rb b/activestorage/app/models/active_storage/current.rb
new file mode 100644
index 0000000000..7e431d8462
--- /dev/null
+++ b/activestorage/app/models/active_storage/current.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ActiveStorage::Current < ActiveSupport::CurrentAttributes #:nodoc:
+ attribute :host
+end
diff --git a/activestorage/app/models/active_storage/filename.rb b/activestorage/app/models/active_storage/filename.rb
index b9413dec95..2a03e0173d 100644
--- a/activestorage/app/models/active_storage/filename.rb
+++ b/activestorage/app/models/active_storage/filename.rb
@@ -5,6 +5,14 @@
class ActiveStorage::Filename
include Comparable
+ class << self
+ # Returns a Filename instance based on the given filename. If the filename is a Filename, it is
+ # returned unmodified. If it is a String, it is passed to ActiveStorage::Filename.new.
+ def wrap(filename)
+ filename.kind_of?(self) ? filename : new(filename)
+ end
+ end
+
def initialize(filename)
@filename = filename
end
@@ -50,10 +58,6 @@ class ActiveStorage::Filename
@filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
end
- def parameters #:nodoc:
- Parameters.new self
- end
-
# Returns the sanitized version of the filename.
def to_s
sanitized.to_s
diff --git a/activestorage/app/models/active_storage/filename/parameters.rb b/activestorage/app/models/active_storage/filename/parameters.rb
deleted file mode 100644
index fb9ea10e49..0000000000
--- a/activestorage/app/models/active_storage/filename/parameters.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-class ActiveStorage::Filename::Parameters #:nodoc:
- attr_reader :filename
-
- def initialize(filename)
- @filename = filename
- end
-
- def combined
- "#{ascii}; #{utf8}"
- end
-
- TRADITIONAL_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/
-
- def ascii
- 'filename="' + percent_escape(I18n.transliterate(filename.sanitized), TRADITIONAL_ESCAPED_CHAR) + '"'
- end
-
- RFC_5987_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/
-
- def utf8
- "filename*=UTF-8''" + percent_escape(filename.sanitized, RFC_5987_ESCAPED_CHAR)
- end
-
- def to_s
- combined
- end
-
- private
- def percent_escape(string, pattern)
- string.gsub(pattern) do |char|
- char.bytes.map { |byte| "%%%02X" % byte }.join
- end
- end
-end
diff --git a/activestorage/app/models/active_storage/preview.rb b/activestorage/app/models/active_storage/preview.rb
index be5053edae..bb9d960443 100644
--- a/activestorage/app/models/active_storage/preview.rb
+++ b/activestorage/app/models/active_storage/preview.rb
@@ -21,10 +21,9 @@
#
# Outside of a Rails application, modify +ActiveStorage.previewers+ instead.
#
-# The built-in previewers rely on third-party system libraries:
-#
-# * {ffmpeg}[https://www.ffmpeg.org]
-# * {mupdf}[https://mupdf.com]
+# The built-in previewers rely on third-party system libraries. Specifically, the built-in video previewer requires
+# {FFmpeg}[https://www.ffmpeg.org]. Two PDF previewers are provided: one requires {Poppler}[https://poppler.freedesktop.org],
+# and the other requires {muPDF}[https://mupdf.com] (version 1.8 or newer). To preview PDFs, install either Poppler or muPDF.
#
# These libraries are not provided by Rails. You must install them yourself to use the built-in previewers. Before you
# install and use third-party software, make sure you understand the licensing implications of doing so.
@@ -39,7 +38,7 @@ class ActiveStorage::Preview
# Processes the preview if it has not been processed yet. Returns the receiving Preview instance for convenience:
#
- # blob.preview(resize: "100x100").processed.service_url
+ # blob.preview(resize_to_limit: [100, 100]).processed.service_url
#
# Processing a preview generates an image from its blob and attaches the preview image to the blob. Because the preview
# image is stored with the blob, it is only generated once.
diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb
index fa5aa69bd3..bc0058967a 100644
--- a/activestorage/app/models/active_storage/variant.rb
+++ b/activestorage/app/models/active_storage/variant.rb
@@ -1,40 +1,60 @@
# frozen_string_literal: true
+require "ostruct"
+
# Image blobs can have variants that are the result of a set of transformations applied to the original.
# These variants are used to create thumbnails, fixed-size avatars, or any other derivative image from the
# original.
#
-# Variants rely on {MiniMagick}[https://github.com/minimagick/minimagick] for the actual transformations
-# of the file, so you must add <tt>gem "mini_magick"</tt> to your Gemfile if you wish to use variants.
+# Variants rely on {ImageProcessing}[https://github.com/janko-m/image_processing] gem for the actual transformations
+# of the file, so you must add <tt>gem "image_processing"</tt> to your Gemfile if you wish to use variants. By
+# default, images will be processed with {ImageMagick}[http://imagemagick.org] using the
+# {MiniMagick}[https://github.com/minimagick/minimagick] gem, but you can also switch to the
+# {libvips}[http://jcupitt.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/jcupitt/ruby-vips]
+# gem).
+#
+# Rails.application.config.active_storage.variant_processor
+# # => :mini_magick
#
-# Note that to create a variant it's necessary to download the entire blob file from the service and load it
-# into memory. The larger the image, the more memory is used. Because of this process, you also want to be
-# considerate about when the variant is actually processed. You shouldn't be processing variants inline in a
-# template, for example. Delay the processing to an on-demand controller, like the one provided in
-# ActiveStorage::VariantsController.
+# Rails.application.config.active_storage.variant_processor = :vips
+# # => :vips
+#
+# Note that to create a variant it's necessary to download the entire blob file from the service. Because of this process,
+# you also want to be considerate about when the variant is actually processed. You shouldn't be processing variants inline
+# in a template, for example. Delay the processing to an on-demand controller, like the one provided in
+# ActiveStorage::RepresentationsController.
#
# To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided
# by Active Storage like so:
#
-# <%= image_tag url_for(Current.user.avatar.variant(resize: "100x100")) %>
+# <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
#
-# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::VariantsController
+# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
# can then produce on-demand.
#
# When you do want to actually produce the variant needed, call +processed+. This will check that the variant
# has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform
# the transformations, upload the variant to the service, and return itself again. Example:
#
-# avatar.variant(resize: "100x100").processed.service_url
+# avatar.variant(resize_to_limit: [100, 100]).processed.service_url
#
# This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
# Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
#
-# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php. You can
-# combine as many as you like freely:
+# You can combine any number of ImageMagick/libvips operations into a variant, as well as any macros provided by the
+# ImageProcessing gem (such as +resize_to_limit+):
+#
+# avatar.variant(resize_to_limit: [800, 800], monochrome: true, rotate: "-90")
#
-# avatar.variant(resize: "100x100", monochrome: true, flip: "-90")
+# Visit the following links for a list of available ImageProcessing commands and ImageMagick/libvips operations:
+#
+# * {ImageProcessing::MiniMagick}[https://github.com/janko-m/image_processing/blob/master/doc/minimagick.md#methods]
+# * {ImageMagick reference}[https://www.imagemagick.org/script/mogrify.php]
+# * {ImageProcessing::Vips}[https://github.com/janko-m/image_processing/blob/master/doc/vips.md#methods]
+# * {ruby-vips reference}[http://www.rubydoc.info/gems/ruby-vips/Vips/Image]
class ActiveStorage::Variant
+ WEB_IMAGE_CONTENT_TYPES = %w[ image/png image/jpeg image/jpg image/gif ]
+
attr_reader :blob, :variation
delegate :service, to: :blob
@@ -59,10 +79,10 @@ class ActiveStorage::Variant
# it allows permanent URLs that redirect to the +service_url+ to be cached in the view.
#
# Use <tt>url_for(variant)</tt> (or the implied form, like +link_to variant+ or +redirect_to variant+) to get the stable URL
- # for a variant that points to the ActiveStorage::VariantsController, which in turn will use this +service_call+ method
+ # for a variant that points to the ActiveStorage::RepresentationsController, which in turn will use this +service_call+ method
# for its redirection.
- def service_url(expires_in: service.url_expires_in, disposition: :inline)
- service.url key, expires_in: expires_in, disposition: disposition, filename: blob.filename, content_type: blob.content_type
+ def service_url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline)
+ service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
end
# Returns the receiving variant. Allows ActiveStorage::Variant and ActiveStorage::Preview instances to be used interchangeably.
@@ -76,11 +96,36 @@ class ActiveStorage::Variant
end
def process
- service.upload key, transform(service.download(blob.key))
+ blob.open do |image|
+ transform(image) { |output| upload(output) }
+ end
end
- def transform(io)
- require "mini_magick"
- File.open MiniMagick::Image.read(io).tap { |image| variation.transform(image) }.path
+ def transform(image, &block)
+ variation.transform(image, format: format, &block)
end
+
+ def upload(file)
+ service.upload(key, file)
+ end
+
+
+ def specification
+ @specification ||=
+ if WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type)
+ Specification.new \
+ filename: blob.filename,
+ content_type: blob.content_type,
+ format: nil
+ else
+ Specification.new \
+ filename: ActiveStorage::Filename.new("#{blob.filename.base}.png"),
+ content_type: "image/png",
+ format: "png"
+ end
+ end
+
+ delegate :filename, :content_type, :format, to: :specification
+
+ class Specification < OpenStruct; end
end
diff --git a/activestorage/app/models/active_storage/variation.rb b/activestorage/app/models/active_storage/variation.rb
index fc4305dcc5..67568772da 100644
--- a/activestorage/app/models/active_storage/variation.rb
+++ b/activestorage/app/models/active_storage/variation.rb
@@ -6,9 +6,9 @@
# In case you do need to use this directly, it's instantiated using a hash of transformations where
# the key is the command and the value is the arguments. Example:
#
-# ActiveStorage::Variation.new(resize: "100x100", monochrome: true, trim: true, rotate: "-90")
+# ActiveStorage::Variation.new(resize_to_limit: [100, 100], monochrome: true, trim: true, rotate: "-90")
#
-# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php.
+# The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands.
class ActiveStorage::Variation
attr_reader :transformations
@@ -43,17 +43,13 @@ class ActiveStorage::Variation
@transformations = transformations
end
- # Accepts an open MiniMagick image instance, like what's returned by <tt>MiniMagick::Image.read(io)</tt>,
- # and performs the +transformations+ against it. The transformed image instance is then returned.
- def transform(image)
- transformations.each do |method, argument|
- image.mogrify do |command|
- if eligible_argument?(argument)
- command.public_send(method, argument)
- else
- command.public_send(method)
- end
- end
+ # Accepts a File object, performs the +transformations+ against it, and
+ # saves the transformed image into a temporary file. If +format+ is specified
+ # it will be the format of the result image, otherwise the result image
+ # retains the source format.
+ def transform(file, format: nil, &block)
+ ActiveSupport::Notifications.instrument("transform.active_storage") do
+ transformer.transform(file, format: format, &block)
end
end
@@ -63,7 +59,22 @@ class ActiveStorage::Variation
end
private
- def eligible_argument?(argument)
- argument.present? && argument != true
+ def transformer
+ if ActiveStorage.variant_processor
+ begin
+ require "image_processing"
+ rescue LoadError
+ ActiveSupport::Deprecation.warn <<~WARNING
+ Generating image variants will require the image_processing gem in Rails 6.1.
+ Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.
+ WARNING
+
+ ActiveStorage::Transformers::MiniMagickTransformer.new(transformations)
+ else
+ ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations)
+ end
+ else
+ ActiveStorage::Transformers::MiniMagickTransformer.new(transformations)
+ end
end
end