diff options
Diffstat (limited to 'activestorage/app/models')
10 files changed, 310 insertions, 147 deletions
diff --git a/activestorage/app/models/active_storage/attachment.rb b/activestorage/app/models/active_storage/attachment.rb index 9f61a5dbf3..19f48c57d6 100644 --- a/activestorage/app/models/active_storage/attachment.rb +++ b/activestorage/app/models/active_storage/attachment.rb @@ -14,7 +14,7 @@ class ActiveStorage::Attachment < ActiveRecord::Base delegate_missing_to :blob - after_create_commit :analyze_blob_later + after_create_commit :identify_blob, :analyze_blob_later # Synchronously purges the blob (deletes it from the configured service) and destroys the attachment. def purge @@ -29,6 +29,10 @@ class ActiveStorage::Attachment < ActiveRecord::Base end private + def identify_blob + blob.identify + end + def analyze_blob_later blob.analyze_later unless blob.analyzed? end diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index 99823e14c6..a1e69e2264 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "active_storage/analyzer/null_analyzer" - # 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,20 +14,17 @@ 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 UnpreviewableError < StandardError; end - class UnrepresentableError < StandardError; end + include Analyzable, Identifiable, 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 - class << self # You can used 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 @@ -68,7 +63,6 @@ class ActiveStorage::Blob < ActiveRecord::Base 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. def signed_id @@ -110,85 +104,16 @@ class ActiveStorage::Blob < ActiveRecord::Base content_type.start_with?("text") 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. - def variant(transformations) - ActiveStorage::Variant.new(self, ActiveStorage::Variation.wrap(transformations)) - 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 instance for a previewable blob or an ActiveStorage::Variant instance for an image blob. - # - # blob.representation(resize: "100x100").processed.service_url - # - # Raises ActiveStorage::Blob::UnrepresentableError if the receiving blob is neither an image 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 image? - variant transformations - else - raise UnrepresentableError - end - end - - # Returns true if the blob is an image or is previewable. - def representable? - image? || 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: service.url_expires_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, + disposition: forcibly_serve_as_binary? ? :attachment : 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 @@ -202,6 +127,7 @@ 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. @@ -213,8 +139,10 @@ class ActiveStorage::Blob < ActiveRecord::Base # 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 + self.checksum = compute_checksum_in_chunks(io) + self.content_type = extract_content_type(io) + self.byte_size = io.size + self.identified = true service.upload(key, io, checksum: checksum) end @@ -226,51 +154,12 @@ class ActiveStorage::Blob < ActiveRecord::Base 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. - # - # 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: 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 - - # 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+ # methods in most circumstances. def delete - service.delete key + service.delete(key) + service.delete_prefixed("variants/#{key}/") if image? end # Deletes the file on the service and then destroys the blob record. This is the recommended way to dispose of unwanted @@ -298,16 +187,11 @@ class ActiveStorage::Blob < ActiveRecord::Base end.base64digest end - - def extract_metadata_via_analyzer - analyzer.metadata.merge(analyzed: true) - end - - def analyzer - analyzer_class.new(self) + def extract_content_type(io) + Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type end - def analyzer_class - ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer + def forcibly_serve_as_binary? + ActiveStorage.content_types_to_serve_as_binary.include?(content_type) end 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..40ca84ac70 --- /dev/null +++ b/activestorage/app/models/active_storage/blob/identifiable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ActiveStorage::Blob::Identifiable + def identify + ActiveStorage::Identification.new(self).apply + end + + def identified? + identified + 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..0ad2e2fd77 --- /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: "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::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, ActiveStorage::Variation.wrap(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: "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::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 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: "100x100").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/filename.rb b/activestorage/app/models/active_storage/filename.rb index 79d55dc889..2b8880716e 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,7 +58,7 @@ class ActiveStorage::Filename @filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-") end - def parameters + def parameters #:nodoc: Parameters.new self end diff --git a/activestorage/app/models/active_storage/filename/parameters.rb b/activestorage/app/models/active_storage/filename/parameters.rb index 58ce198d38..fb9ea10e49 100644 --- a/activestorage/app/models/active_storage/filename/parameters.rb +++ b/activestorage/app/models/active_storage/filename/parameters.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ActiveStorage::Filename::Parameters +class ActiveStorage::Filename::Parameters #:nodoc: attr_reader :filename def initialize(filename) diff --git a/activestorage/app/models/active_storage/identification.rb b/activestorage/app/models/active_storage/identification.rb new file mode 100644 index 0000000000..4f295257ae --- /dev/null +++ b/activestorage/app/models/active_storage/identification.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActiveStorage::Identification + attr_reader :blob + + def initialize(blob) + @blob = blob + end + + def apply + blob.update!(content_type: content_type, identified: true) unless blob.identified? + end + + private + def content_type + Marcel::MimeType.for(identifiable_chunk, name: filename, declared_type: declared_content_type) + end + + + def identifiable_chunk + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |client| + client.get(uri, "Range" => "0-4096").body + end + end + + def uri + @uri ||= URI.parse(blob.service_url) + end + + + def filename + blob.filename.to_s + end + + def declared_content_type + blob.content_type + end +end diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb index fa5aa69bd3..e08a2271ec 100644 --- a/activestorage/app/models/active_storage/variant.rb +++ b/activestorage/app/models/active_storage/variant.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_storage/downloading" + # 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. @@ -16,7 +18,7 @@ # 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: "100x100") %> # # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::VariantsController # can then produce on-demand. @@ -35,6 +37,10 @@ # # avatar.variant(resize: "100x100", monochrome: true, flip: "-90") class ActiveStorage::Variant + include ActiveStorage::Downloading + + WEB_IMAGE_CONTENT_TYPES = %w( image/png image/jpeg image/jpg image/gif ) + attr_reader :blob, :variation delegate :service, to: :blob @@ -62,7 +68,7 @@ class ActiveStorage::Variant # for a variant that points to the ActiveStorage::VariantsController, 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 + 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 +82,51 @@ class ActiveStorage::Variant end def process - service.upload key, transform(service.download(blob.key)) + open_image do |image| + transform image + format image + upload image + end + end + + + def filename + if WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type) + blob.filename + else + ActiveStorage::Filename.new("#{blob.filename.base}.png") + end + end + + def content_type + blob.content_type.presence_in(WEB_IMAGE_CONTENT_TYPES) || "image/png" + end + + + def open_image(&block) + image = download_image + + begin + yield image + ensure + image.destroy! + end end - def transform(io) + def download_image require "mini_magick" - File.open MiniMagick::Image.read(io).tap { |image| variation.transform(image) }.path + MiniMagick::Image.create { |file| download_blob_to(file) } + end + + def transform(image) + variation.transform(image) + end + + def format(image) + image.format("PNG") unless WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type) + end + + def upload(image) + File.open(image.path, "r") { |file| service.upload(key, file) } end end diff --git a/activestorage/app/models/active_storage/variation.rb b/activestorage/app/models/active_storage/variation.rb index 13bad87cac..12e7f9f0b5 100644 --- a/activestorage/app/models/active_storage/variation.rb +++ b/activestorage/app/models/active_storage/variation.rb @@ -8,6 +8,14 @@ # # ActiveStorage::Variation.new(resize: "100x100", monochrome: true, trim: true, rotate: "-90") # +# You can also combine multiple transformations in one step, e.g. for center-weighted cropping: +# +# ActiveStorage::Variation.new(combine_options: { +# resize: "100x100^", +# gravity: "center", +# crop: "100x100+0+0", +# }) +# # A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php. class ActiveStorage::Variation attr_reader :transformations @@ -46,11 +54,17 @@ class ActiveStorage::Variation # 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)| - if eligible_argument?(argument) - image.public_send(method, argument) - else - image.public_send(method) + ActiveSupport::Notifications.instrument("transform.active_storage") do + transformations.each do |name, argument_or_subtransformations| + image.mogrify do |command| + if name.to_s == "combine_options" + argument_or_subtransformations.each do |subtransformation_name, subtransformation_argument| + pass_transform_argument(command, subtransformation_name, subtransformation_argument) + end + else + pass_transform_argument(command, name, argument_or_subtransformations) + end + end end end end @@ -61,6 +75,14 @@ class ActiveStorage::Variation end private + def pass_transform_argument(command, method, argument) + if eligible_argument?(argument) + command.public_send(method, argument) + else + command.public_send(method) + end + end + def eligible_argument?(argument) argument.present? && argument != true end |