diff options
Diffstat (limited to 'activestorage/app/models')
10 files changed, 847 insertions, 0 deletions
diff --git a/activestorage/app/models/active_storage/attachment.rb b/activestorage/app/models/active_storage/attachment.rb new file mode 100644 index 0000000000..13758d9179 --- /dev/null +++ b/activestorage/app/models/active_storage/attachment.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +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. 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" + + belongs_to :record, polymorphic: true, touch: true + belongs_to :blob, class_name: "ActiveStorage::Blob" + + delegate_missing_to :blob + + after_create_commit :analyze_blob_later, :identify_blob + after_destroy_commit :purge_dependent_blob_later + + # Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge]. + def purge + delete + blob&.purge + end + + # Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob. + def purge_later + 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 new file mode 100644 index 0000000000..53aa9f0237 --- /dev/null +++ b/activestorage/app/models/active_storage/blob.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +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: +# +# 1. Subsequent to the file being uploaded server-side to the service via <tt>create_after_upload!</tt>. +# 2. Ahead of the file being directly uploaded client-side to the service via <tt>create_before_direct_upload!</tt>. +# +# The first option doesn't require any client-side JavaScript integration, and can be used by any other back-end +# service that deals with files. The second option is faster, since you're not using your own server as a staging +# point for uploads, and can work with deployments like Heroku that do not provide large amounts of disk space. +# +# Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to +# 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 + 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, :identified ], coder: ActiveRecord::Coders::JSON + + class_attribute :service + + has_many :attachments + + 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. + # 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. + # + # The signed ID is also used to create stable URLs for the blob through the BlobsController. + def find_signed(id) + find ActiveStorage.verifier.verify(id, purpose: :blob_id) + end + + # Returns a new, unsaved blob instance after the +io+ has been uploaded to the service. + # 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 + + 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. + # 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 + # no file yet. It's intended to be used together with a client-side upload, which will first create the blob + # in order to produce the signed URL for uploading. This signed URL points to the key generated by the blob. + # Once the form using the direct upload is submitted, the blob can be associated with the right record using + # the signed ID. + 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 + + # 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 + 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. + 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 + end + + # Returns an ActiveStorage::Filename instance of the filename that can be + # queried for basename, extension, and a sanitized version of the filename + # that's safe to use in URLs. + def filename + ActiveStorage::Filename.new(self[:filename]) + end + + # Returns true if the content_type of this blob is in the image range, like image/png. + def image? + content_type.start_with?("image") + end + + # Returns true if the content_type of this blob is in the audio range, like audio/mpeg. + def audio? + content_type.start_with?("audio") + end + + # Returns true if the content_type of this blob is in the video range, like video/mp4. + def video? + content_type.start_with?("video") + end + + # Returns true if the content_type of this blob is in the text range, like text/plain. + def text? + content_type.start_with?("text") + 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: 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, + 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 + # 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: 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 + + # Returns a Hash of headers for +service_url_for_direct_upload+ requests. + def service_headers_for_direct_upload + 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. 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, identify: true) + unfurl io, identify: identify + upload_without_unfurling io + end + + 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 + end + + # Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned. + # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks. + def download(&block) + service.download key, &block + end + + # Downloads the blob to a tempfile on disk. Yields the tempfile. + # + # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob. + # + # By default, the tempfile is created in <tt>Dir.tmpdir</tt>. Pass +tempdir:+ to create it in a different directory: + # + # blob.open(tempdir: "/path/to/tmp") do |file| + # # ... + # end + # + # The tempfile is automatically closed and unlinked after the given block is executed. + # + # 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 + + + # 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) + 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 + # 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. + def purge + destroy + delete + rescue ActiveRecord::InvalidForeignKey + end + + # 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 + + private + def compute_checksum_in_chunks(io) + Digest::MD5.new.tap do |checksum| + while chunk = io.read(5.megabytes) + checksum << chunk + end + + io.rewind + end.base64digest + end + + def extract_content_type(io) + Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type + end + + def forcibly_serve_as_binary? + ActiveStorage.content_types_to_serve_as_binary.include?(content_type) + 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..2c17ddc25f --- /dev/null +++ b/activestorage/app/models/active_storage/blob/identifiable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActiveStorage::Blob::Identifiable + def identify + update! content_type: identify_content_type, identified: true unless identified? + 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 +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..03d5511481 --- /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_fit: [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_fit: [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_fit: [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_fit: [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_fit: [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 new file mode 100644 index 0000000000..2a03e0173d --- /dev/null +++ b/activestorage/app/models/active_storage/filename.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Encapsulates a string representing a filename to provide convenient access to parts of it and sanitization. +# A Filename instance is returned by ActiveStorage::Blob#filename, and is comparable so it can be used for sorting. +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 + + # Returns the part of the filename preceding any extension. + # + # ActiveStorage::Filename.new("racecar.jpg").base # => "racecar" + # ActiveStorage::Filename.new("racecar").base # => "racecar" + # ActiveStorage::Filename.new(".gitignore").base # => ".gitignore" + def base + File.basename @filename, extension_with_delimiter + end + + # Returns the extension of the filename (i.e. the substring following the last dot, excluding a dot at the + # beginning) with the dot that precedes it. If the filename has no extension, an empty string is returned. + # + # ActiveStorage::Filename.new("racecar.jpg").extension_with_delimiter # => ".jpg" + # ActiveStorage::Filename.new("racecar").extension_with_delimiter # => "" + # ActiveStorage::Filename.new(".gitignore").extension_with_delimiter # => "" + def extension_with_delimiter + File.extname @filename + end + + # Returns the extension of the filename (i.e. the substring following the last dot, excluding a dot at + # the beginning). If the filename has no extension, an empty string is returned. + # + # ActiveStorage::Filename.new("racecar.jpg").extension_without_delimiter # => "jpg" + # ActiveStorage::Filename.new("racecar").extension_without_delimiter # => "" + # ActiveStorage::Filename.new(".gitignore").extension_without_delimiter # => "" + def extension_without_delimiter + extension_with_delimiter.from(1).to_s + end + + alias_method :extension, :extension_without_delimiter + + # Returns the sanitized filename. + # + # ActiveStorage::Filename.new("foo:bar.jpg").sanitized # => "foo-bar.jpg" + # ActiveStorage::Filename.new("foo/bar.jpg").sanitized # => "foo-bar.jpg" + # + # Characters considered unsafe for storage (e.g. \, $, and the RTL override character) are replaced with a dash. + def sanitized + @filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-") + end + + # Returns the sanitized version of the filename. + def to_s + sanitized.to_s + end + + def as_json(*) + to_s + end + + def to_json + to_s + end + + def <=>(other) + to_s.downcase <=> other.to_s.downcase + end +end diff --git a/activestorage/app/models/active_storage/preview.rb b/activestorage/app/models/active_storage/preview.rb new file mode 100644 index 0000000000..dd50494799 --- /dev/null +++ b/activestorage/app/models/active_storage/preview.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Some non-image blobs can be previewed: that is, they can be presented as images. A video blob can be previewed by +# extracting its first frame, and a PDF blob can be previewed by extracting its first page. +# +# A previewer extracts a preview image from a blob. Active Storage provides previewers for videos and PDFs: +# ActiveStorage::Previewer::VideoPreviewer and ActiveStorage::Previewer::PDFPreviewer. Build custom previewers by +# subclassing ActiveStorage::Previewer and implementing the requisite methods. Consult the ActiveStorage::Previewer +# documentation for more details on what's required of previewers. +# +# To choose the previewer for a blob, Active Storage calls +accept?+ on each registered previewer in order. It uses the +# first previewer for which +accept?+ returns true when given the blob. In a Rails application, add or remove previewers +# by manipulating +Rails.application.config.active_storage.previewers+ in an initializer: +# +# Rails.application.config.active_storage.previewers +# # => [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ] +# +# # Add a custom previewer for Microsoft Office documents: +# Rails.application.config.active_storage.previewers << DOCXPreviewer +# # => [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer, DOCXPreviewer ] +# +# Outside of a Rails application, modify +ActiveStorage.previewers+ instead. +# +# 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. +class ActiveStorage::Preview + class UnprocessedError < StandardError; end + + attr_reader :blob, :variation + + def initialize(blob, variation_or_variation_key) + @blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key) + end + + # Processes the preview if it has not been processed yet. Returns the receiving Preview instance for convenience: + # + # blob.preview(resize_to_fit: [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. + def processed + process unless processed? + self + end + + # Returns the blob's attached preview image. + def image + blob.preview_image + end + + # Returns the URL of the preview's variant on the service. Raises ActiveStorage::Preview::UnprocessedError if the + # preview has not been processed yet. + # + # This method synchronously processes a variant of the preview image, so do not call it in views. Instead, generate + # a stable URL that redirects to the short-lived URL returned by this method. + def service_url(**options) + if processed? + variant.service_url(options) + else + raise UnprocessedError + end + end + + private + def processed? + image.attached? + end + + def process + previewer.preview { |attachable| image.attach(attachable) } + end + + def variant + ActiveStorage::Variant.new(image, variation).processed + end + + + def previewer + previewer_class.new(blob) + end + + def previewer_class + ActiveStorage.previewers.detect { |klass| klass.accept?(blob) } + end +end diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb new file mode 100644 index 0000000000..ea57fa5f78 --- /dev/null +++ b/activestorage/app/models/active_storage/variant.rb @@ -0,0 +1,131 @@ +# 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 {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 +# +# 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 Current.user.avatar.variant(resize_to_fit: [100, 100]) %> +# +# 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_to_fit: [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. +# +# 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_fit+): +# +# avatar.variant(resize_to_fit: [800, 800], monochrome: true, rotate: "-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 + + def initialize(blob, variation_or_variation_key) + @blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key) + end + + # Returns the variant instance itself after it's been processed or an existing processing has been found on the service. + def processed + process unless processed? + self + end + + # Returns a combination key of the blob and the variation that together identifies a specific variant. + def key + "variants/#{blob.key}/#{Digest::SHA256.hexdigest(variation.key)}" + end + + # Returns the URL of the variant 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. + # + # 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::RepresentationsController, which in turn will use this +service_call+ method + # for its redirection. + 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. + def image + self + end + + private + def processed? + service.exist?(key) + end + + def process + blob.open do |image| + transform(image) { |output| upload(output) } + end + end + + 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 new file mode 100644 index 0000000000..3adc2407e5 --- /dev/null +++ b/activestorage/app/models/active_storage/variation.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# A set of transformations that can be applied to a blob to create a variant. This class is exposed via +# the ActiveStorage::Blob#variant method and should rarely be used directly. +# +# 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_to_fit: [100, 100], monochrome: true, trim: true, rotate: "-90") +# +# The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands. +class ActiveStorage::Variation + attr_reader :transformations + + class << self + # Returns a Variation instance based on the given variator. If the variator is a Variation, it is + # returned unmodified. If it is a String, it is passed to ActiveStorage::Variation.decode. Otherwise, + # it is assumed to be a transformations Hash and is passed directly to the constructor. + def wrap(variator) + case variator + when self + variator + when String + decode variator + else + new variator + end + end + + # Returns a Variation instance with the transformations that were encoded by +encode+. + def decode(key) + new ActiveStorage.verifier.verify(key, purpose: :variation) + end + + # Returns a signed key for the +transformations+, which can be used to refer to a specific + # variation in a URL or combined key (like <tt>ActiveStorage::Variant#key</tt>). + def encode(transformations) + ActiveStorage.verifier.generate(transformations, purpose: :variation) + end + end + + def initialize(transformations) + @transformations = transformations + 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 + + # Returns a signed key for all the +transformations+ that this variation was instantiated with. + def key + self.class.encode(transformations) + end + + private + 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 |