All Files ( 74.52% covered at 76.22 hits/line )
67 files in total.
1786 relevant lines,
1331 lines covered and
455 lines missed.
(
74.52%
)
# frozen_string_literal: true
# The base class for all Active Storage controllers.
- 3
class ActiveStorage::BaseController < ActionController::Base
- 3
include ActiveStorage::SetCurrent
- 3
protect_from_forgery with: :exception
- 3
self.etag_with_template_digest = false
- 3
private
- 3
def stream(blob)
- 8
blob.download do |chunk|
- 8
response.stream.write chunk
end
ensure
- 8
response.stream.close
end
end
# frozen_string_literal: true
# Proxy files through application. This avoids having a redirect and makes files easier to cache.
- 2
class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
- 2
include ActiveStorage::SetBlob
- 2
include ActiveStorage::SetHeaders
- 2
def show
- 2
http_cache_forever public: true do
- 2
set_content_headers_from @blob
- 2
stream @blob
end
end
end
# frozen_string_literal: true
# Take a signed permanent reference for a blob and turn it into an expiring service URL for download.
# Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
# security-through-obscurity factor of the signed blob references, you'll need to implement your own
# authenticated redirection controller.
- 3
class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
- 3
include ActiveStorage::SetBlob
- 3
def show
- 3
expires_in ActiveStorage.service_urls_expire_in
- 3
redirect_to @blob.url(disposition: params[:disposition])
end
end
# frozen_string_literal: true
# Creates a new blob on the server side in anticipation of a direct-to-service upload from the client side.
# When the client-side upload is completed, the signed_blob_id can be submitted as part of the form to reference
# the blob that was created up front.
- 3
class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
- 3
def create
- 6
blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
- 6
render json: direct_upload_json(blob)
end
- 3
private
- 3
def blob_args
- 6
params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :metadata).to_h.symbolize_keys
end
- 3
def direct_upload_json(blob)
- 6
blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
url: blob.service_url_for_direct_upload,
headers: blob.service_headers_for_direct_upload
})
end
end
# frozen_string_literal: true
# Serves files stored with the disk service in the same way that the cloud services do.
# This means using expiring, signed URLs that are meant for immediate access, not permanent linking.
# Always go through the BlobsController, or your own authenticated controller, rather than directly
# to the service URL.
- 3
class ActiveStorage::DiskController < ActiveStorage::BaseController
- 3
include ActiveStorage::FileServer
- 3
skip_forgery_protection
- 3
def show
- 27
if key = decode_verified_key
- 24
serve_file named_disk_service(key[:service_name]).path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
else
- 3
head :not_found
end
rescue Errno::ENOENT
- 3
head :not_found
end
- 3
def update
- 18
if token = decode_verified_token
- 15
if acceptable_content?(token)
- 6
named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
else
- 9
head :unprocessable_entity
end
else
- 3
head :not_found
end
rescue ActiveStorage::IntegrityError
head :unprocessable_entity
end
- 3
private
- 3
def named_disk_service(name)
- 30
ActiveStorage::Blob.services.fetch(name) do
ActiveStorage::Blob.service
end
end
- 3
def decode_verified_key
- 27
ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
end
- 3
def decode_verified_token
- 18
ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
end
- 3
def acceptable_content?(token)
- 15
token[:content_type] == request.content_mime_type && token[:content_length] == request.content_length
end
end
# frozen_string_literal: true
# Proxy files through application. This avoids having a redirect and makes files easier to cache.
- 3
class ActiveStorage::Representations::ProxyController < ActiveStorage::BaseController
- 3
include ActiveStorage::SetBlob
- 3
include ActiveStorage::SetHeaders
- 3
def show
- 6
http_cache_forever public: true do
- 6
set_content_headers_from representation.image
- 6
stream representation
end
end
- 3
private
- 3
def representation
- 12
@representation ||= @blob.representation(params[:variation_key]).processed
end
end
# frozen_string_literal: true
# Take a signed permanent reference for a blob representation and turn it into an expiring service URL for download.
# Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
# security-through-obscurity factor of the signed blob and variation reference, you'll need to implement your own
# authenticated redirection controller.
- 3
class ActiveStorage::Representations::RedirectController < ActiveStorage::BaseController
- 3
include ActiveStorage::SetBlob
- 3
def show
- 6
expires_in ActiveStorage.service_urls_expire_in
- 6
redirect_to @blob.representation(params[:variation_key]).processed.url(disposition: params[:disposition])
end
end
# frozen_string_literal: true
- 3
module ActiveStorage::FileServer # :nodoc:
- 3
private
- 3
def serve_file(path, content_type:, disposition:)
- 24
Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
- 21
self.status = status
- 21
self.response_body = body
- 21
headers.each do |name, value|
- 66
response.headers[name] = value
end
- 21
response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
- 21
response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage::SetBlob #:nodoc:
- 3
extend ActiveSupport::Concern
- 3
included do
- 11
before_action :set_blob
end
- 3
private
- 3
def set_blob
- 34
@blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
rescue ActiveSupport::MessageVerifier::InvalidSignature
- 17
head :not_found
end
end
# frozen_string_literal: true
# Sets the <tt>ActiveStorage::Current.host</tt> attribute, which the disk service uses to generate URLs.
# Include this concern in custom controllers that call ActiveStorage::Blob#url,
# ActiveStorage::Variant#url, or ActiveStorage::Preview#url so the disk service can
# generate URLs using the same host, protocol, and base path as the current request.
- 3
module ActiveStorage::SetCurrent
- 3
extend ActiveSupport::Concern
- 3
included do
- 3
before_action do
- 85
ActiveStorage::Current.host = request.base_url
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage::SetHeaders #:nodoc:
- 3
extend ActiveSupport::Concern
- 3
private
- 3
def set_content_headers_from(blob)
- 8
response.headers["Content-Type"] = blob.content_type
- 8
response.headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format \
disposition: params[:disposition] || "inline", filename: blob.filename.sanitized
end
end
# frozen_string_literal: true
# Provides asynchronous analysis of ActiveStorage::Blob records via ActiveStorage::Blob#analyze_later.
- 3
class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob
- 150
queue_as { ActiveStorage.queues[:analysis] }
- 3
discard_on ActiveRecord::RecordNotFound
- 3
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
- 3
def perform(blob)
- 20
blob.analyze
end
end
# frozen_string_literal: true
- 3
class ActiveStorage::BaseJob < ActiveJob::Base
end
# frozen_string_literal: true
- 3
require "active_support/core_ext/object/try"
# Provides asynchronous mirroring of directly-uploaded blobs.
- 3
class ActiveStorage::MirrorJob < ActiveStorage::BaseJob
- 14
queue_as { ActiveStorage.queues[:mirror] }
- 3
discard_on ActiveStorage::FileNotFoundError
- 3
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
- 3
def perform(key, checksum:)
- 3
ActiveStorage::Blob.service.try(:mirror, key, checksum: checksum)
end
end
# frozen_string_literal: true
# Provides asynchronous purging of ActiveStorage::Blob records via ActiveStorage::Blob#purge_later.
- 2
class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
- 44
queue_as { ActiveStorage.queues[:purge] }
- 2
discard_on ActiveRecord::RecordNotFound
- 2
retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer
- 2
def perform(blob)
- 34
blob.purge
end
end
# frozen_string_literal: true
- 3
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.
#
# Attachments also have access to all methods from {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob].
- 3
class ActiveStorage::Attachment < ActiveRecord::Base
- 3
self.table_name = "active_storage_attachments"
- 3
belongs_to :record, polymorphic: true, touch: true
- 3
belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true
- 3
delegate_missing_to :blob
- 3
delegate :signed_id, to: :blob
- 3
after_create_commit :mirror_blob_later, :analyze_blob_later
- 3
after_destroy_commit :purge_dependent_blob_later
# Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge].
- 3
def purge
- 14
transaction do
- 14
delete
- 14
record&.touch
end
- 14
blob&.purge
end
# Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob.
- 3
def purge_later
- 14
transaction do
- 14
delete
- 14
record&.touch
end
- 14
blob&.purge_later
end
- 3
private
- 3
def analyze_blob_later
- 381
blob.analyze_later unless blob.analyzed?
end
- 3
def mirror_blob_later
- 380
blob.mirror_later
end
- 3
def purge_dependent_blob_later
- 52
blob&.purge_later if dependent == :purge_later
end
- 3
def dependent
- 52
record.attachment_reflections[name]&.options[:dependent]
end
end
- 3
ActiveSupport.run_load_hooks :active_storage_attachment, ActiveStorage::Attachment
# frozen_string_literal: true
# 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. Ahead of the file being uploaded server-side to the service, via <tt>create_and_upload!</tt>. A rewindable
# <tt>io</tt> with the file contents must be available at the server for this operation.
# 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.
- 3
class ActiveStorage::Blob < ActiveRecord::Base
# We use constant paths in the following include calls to avoid a gotcha of
# classic mode: If the parent application defines a top-level Analyzable, for
# example, and ActiveStorage::Blob::Analyzable is not yet loaded, a bare
#
# include Analyzable
#
# would resolve to the top-level one, const_missing would not be triggered,
# and therefore ActiveStorage::Blob::Analyzable would not be autoloaded.
#
# By using qualified names, we ensure const_missing is invoked if needed.
# Please, note that Ruby 2.5 or newer is required, so Object is not checked
# when looking up the ancestors of ActiveStorage::Blob.
#
# Zeitwerk mode does not have this gotcha. If we ever drop classic mode, this
# can be simplified, bare constant names would just work.
- 3
include ActiveStorage::Blob::Analyzable
- 3
include ActiveStorage::Blob::Identifiable
- 3
include ActiveStorage::Blob::Representable
- 3
self.table_name = "active_storage_blobs"
- 3
self.signed_id_verifier = ActiveStorage.verifier
- 3
MINIMUM_TOKEN_LENGTH = 28
- 3
has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
- 3
store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
- 3
class_attribute :services, default: {}
- 3
class_attribute :service, instance_accessor: false
- 3
has_many :attachments
- 9
scope :unattached, -> { where.missing(:attachments) }
- 3
after_initialize do
- 1229
self.service_name ||= self.class.service.name
end
- 3
after_update_commit :update_service_metadata, if: :content_type_previously_changed?
- 3
before_destroy(prepend: true) do
- 58
raise ActiveRecord::InvalidForeignKey if attachments.exists?
end
- 3
validates :service_name, presence: true
- 3
validate do
- 967
if service_name_changed? && service_name.present?
- 658
services.fetch(service_name) do
- 4
errors.add(:service_name, :invalid)
end
end
end
- 3
class << self
# 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.
#
# The signed ID is also used to create stable URLs for the blob through the BlobsController.
- 3
def find_signed!(id, record: nil)
- 71
super(id, purpose: :blob_id)
end
- 3
def build_after_upload(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
- 2
new(filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
- 2
blob.upload(io, identify: identify)
end
end
- 3
deprecate :build_after_upload
- 3
def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
- 622
new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
- 622
blob.unfurl(io, identify: identify)
end
end
- 3
def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
- 488
build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap(&:save!)
end
# Creates a new blob instance and then uploads the contents of
# the given <tt>io</tt> to the service. The blob instance is going to
# be saved before the upload begins to prevent the upload clobbering another due to key collisions.
# When providing a content type, pass <tt>identify: false</tt> to bypass
# automatic content type inference.
- 3
def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
- 488
create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob|
- 486
blob.upload_without_unfurling(io)
end
end
- 3
alias_method :create_after_upload!, :create_and_upload!
- 3
deprecate create_after_upload!: :create_and_upload!
# 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.
- 3
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
- 48
create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
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
- 3
def generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
- 666
SecureRandom.base36(length)
end
# Customize signed ID purposes for backwards compatibility.
- 3
def combine_signed_id_purposes(purpose)
- 153
purpose.to_s
end
end
# Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
- 3
def signed_id
- 82
super(purpose: :blob_id)
end
# 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.
- 3
def key
# We can't wait until the record is first saved to have a key for it
- 1832
self[:key] ||= self.class.generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
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.
- 3
def filename
- 1606
ActiveStorage::Filename.new(self[:filename])
end
# Returns true if the content_type of this blob is in the image range, like image/png.
- 3
def image?
- 984
content_type.start_with?("image")
end
# Returns true if the content_type of this blob is in the audio range, like audio/mpeg.
- 3
def audio?
- 6
content_type.start_with?("audio")
end
# Returns true if the content_type of this blob is in the video range, like video/mp4.
- 3
def video?
- 505
content_type.start_with?("video")
end
# Returns true if the content_type of this blob is in the text range, like text/plain.
- 3
def text?
- 2
content_type.start_with?("text")
end
# Returns the URL of the blob on the service. This returns a permanent URL for public files, and returns a
# short-lived URL for private files. Private files are signed, and not for public use. Instead,
# the URL should only be exposed as a redirect from a stable, possibly authenticated URL. Hiding the
# URL behind a redirect also allows you to change services without updating all URLs.
- 3
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
- 46
filename = ActiveStorage::Filename.wrap(filename || self.filename)
- 46
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
- 3
alias_method :service_url, :url
- 3
deprecate service_url: :url
# 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.
- 3
def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
- 21
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.
- 3
def service_headers_for_direct_upload
- 6
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 +create_and_upload!+ class method instead.
# If you do use this method directly, make sure you are using it on a persisted Blob as otherwise another blob's
# data might get overwritten on the service.
- 3
def upload(io, identify: true)
- 2
unfurl io, identify: identify
- 2
upload_without_unfurling io
end
- 3
def unfurl(io, identify: true) #:nodoc:
- 624
self.checksum = compute_checksum_in_chunks(io)
- 624
self.content_type = extract_content_type(io) if content_type.nil? || identify
- 624
self.byte_size = io.size
- 624
self.identified = true
end
- 3
def upload_without_unfurling(io) #:nodoc:
- 606
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.
# 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.
- 3
def download(&block)
- 20
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 +tmpdir:+ to create it in a different directory:
#
# blob.open(tmpdir: "/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.
- 3
def open(tmpdir: nil, &block)
- 153
service.open key, checksum: checksum,
name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ], tmpdir: tmpdir, &block
end
- 3
def mirror_later #:nodoc:
- 380
ActiveStorage::MirrorJob.perform_later(key, checksum: checksum) if service.respond_to?(:mirror)
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.
- 3
def delete
- 351
service.delete(key)
- 351
service.delete_prefixed("variants/#{key}/") if image?
end
# Destroys the blob record and then deletes the file on the service. This is the recommended way to dispose of unwanted
# blobs. Note, though, that deleting the file off the service will initiate an 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.
- 3
def purge
- 58
destroy
- 44
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.
- 3
def purge_later
- 40
ActiveStorage::PurgeJob.perform_later(self)
end
# Returns an instance of service, which can be configured globally or per attachment
- 3
def service
- 1999
services.fetch(service_name)
end
- 3
private
- 3
def compute_checksum_in_chunks(io)
Digest::MD5.new.tap do |checksum|
- 624
while chunk = io.read(5.megabytes)
- 624
checksum << chunk
end
- 624
io.rewind
- 624
end.base64digest
end
- 3
def extract_content_type(io)
- 622
Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type
end
- 3
def forcibly_serve_as_binary?
- 714
ActiveStorage.content_types_to_serve_as_binary.include?(content_type)
end
- 3
def allowed_inline?
- 660
ActiveStorage.content_types_allowed_inline.include?(content_type)
end
- 3
def content_type_for_service_url
- 46
forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : content_type
end
- 3
def forced_disposition_for_service_url
- 46
if forcibly_serve_as_binary? || !allowed_inline?
- 31
:attachment
end
end
- 3
def service_metadata
- 622
if forcibly_serve_as_binary?
- 4
{ content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
- 618
elsif !allowed_inline?
- 333
{ content_type: content_type, disposition: :attachment, filename: filename }
else
- 285
{ content_type: content_type }
end
end
- 3
def update_service_metadata
- 8
service.update_metadata key, **service_metadata if service_metadata.any?
end
end
- 3
ActiveSupport.run_load_hooks :active_storage_blob, ActiveStorage::Blob
# frozen_string_literal: true
- 3
require "active_storage/analyzer/null_analyzer"
- 3
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.
- 3
def analyze
- 262
update! metadata: metadata.merge(extract_metadata_via_analyzer)
end
# Enqueues an ActiveStorage::AnalyzeJob which calls #analyze, or calls #analyze inline based on analyzer class configuration.
#
# 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).
- 3
def analyze_later
- 369
if analyzer_class.analyze_later?
- 145
ActiveStorage::AnalyzeJob.perform_later(self)
else
- 224
analyze
end
end
# Returns true if the blob has been analyzed.
- 3
def analyzed?
- 406
analyzed
end
- 3
private
- 3
def extract_metadata_via_analyzer
- 262
analyzer.metadata.merge(analyzed: true)
end
- 3
def analyzer
- 262
analyzer_class.new(self)
end
- 3
def analyzer_class
- 1734
ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer
end
end
# frozen_string_literal: true
- 3
module ActiveStorage::Blob::Identifiable
- 3
def identify
identify_without_saving
save!
end
- 3
def identify_without_saving
- 643
unless identified?
- 19
self.content_type = identify_content_type
- 19
self.identified = true
end
end
- 3
def identified?
- 643
identified
end
- 3
private
- 3
def identify_content_type
- 19
Marcel::MimeType.for download_identifiable_chunk, name: filename.to_s, declared_type: content_type
end
- 3
def download_identifiable_chunk
- 19
if byte_size.positive?
- 19
service.download_chunk key, 0...4.kilobytes
else
""
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage::Blob::Representable
- 3
extend ActiveSupport::Concern
- 3
included do
- 3
has_many :variant_records, class_name: "ActiveStorage::VariantRecord", dependent: false
- 47
before_destroy { variant_records.destroy_all if ActiveStorage.track_variants }
- 3
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.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?.
- 3
def variant(transformations)
- 103
if variable?
- 100
variant_class.new(self, transformations)
else
- 3
raise ActiveStorage::InvariableError
end
end
# Returns true if ImageMagick can transform the blob (its content type is in +ActiveStorage.variable_content_types+).
- 3
def variable?
- 113
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.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?.
- 3
def preview(transformations)
- 31
if previewable?
- 28
ActiveStorage::Preview.new(self, transformations)
else
- 3
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.
- 3
def previewable?
- 148
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.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.
- 3
def representation(transformations)
case
when previewable?
- 10
preview transformations
when variable?
- 8
variant transformations
else
- 2
raise ActiveStorage::UnrepresentableError
- 20
end
end
# Returns true if the blob is variable or previewable.
- 3
def representable?
variable? || previewable?
end
- 3
private
- 3
def variant_class
- 100
ActiveStorage.track_variants ? ActiveStorage::VariantWithRecord : ActiveStorage::Variant
end
end
# frozen_string_literal: true
- 3
class ActiveStorage::Current < ActiveSupport::CurrentAttributes #:nodoc:
- 3
attribute :host
end
# 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.
- 3
class ActiveStorage::Filename
- 3
include Comparable
- 3
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.
- 3
def wrap(filename)
- 46
filename.kind_of?(self) ? filename : new(filename)
end
end
- 3
def initialize(filename)
- 1716
@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"
- 3
def base
- 53
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 # => ""
- 3
def extension_with_delimiter
- 224
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 # => ""
- 3
def extension_without_delimiter
- 9
extension_with_delimiter.from(1).to_s
end
- 3
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.
- 3
def sanitized
- 1290
@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.
- 3
def to_s
- 1115
sanitized.to_s
end
- 3
def as_json(*)
- 6
to_s
end
- 3
def to_json
to_s
end
- 3
def <=>(other)
- 10
to_s.downcase <=> other.to_s.downcase
end
end
# 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 is used for videos whereas ActiveStorage::Previewer::PopplerPDFPreviewer
# and ActiveStorage::Previewer::MuPDFPreviewer are used for PDFs. 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::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
#
# # Add a custom previewer for Microsoft Office documents:
# Rails.application.config.active_storage.previewers << DOCXPreviewer
# # => [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, 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.
- 3
class ActiveStorage::Preview
- 3
class UnprocessedError < StandardError; end
- 3
attr_reader :blob, :variation
- 3
def initialize(blob, variation_or_variation_key)
- 28
@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_limit: [100, 100]).processed.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.
- 3
def processed
- 25
process unless processed?
- 25
self
end
# Returns the blob's attached preview image.
- 3
def image
- 99
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 URL returned by this method.
- 3
def url(**options)
- 3
if processed?
- 3
variant.url(**options)
else
raise UnprocessedError
end
end
- 3
alias_method :service_url, :url
- 3
deprecate service_url: :url
# Returns a combination key of the blob and the variation that together identifies a specific variant.
- 3
def key
if processed?
variant.key
else
raise UnprocessedError
end
end
- 3
def download(&block)
- 3
if processed?
- 3
variant.download(&block)
else
raise UnprocessedError
end
end
- 3
private
- 3
def processed?
- 31
image.attached?
end
- 3
def process
- 25
previewer.preview(service_name: blob.service_name) do |attachable|
- 25
ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do
- 25
image.attach(attachable)
end
end
end
- 3
def variant
- 6
image.variant(variation).processed
end
- 3
def previewer
- 25
previewer_class.new(blob)
end
- 3
def previewer_class
- 66
ActiveStorage.previewers.detect { |klass| klass.accept?(blob) }
end
end
# frozen_string_literal: true
- 3
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://libvips.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/libvips/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_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.
#
# 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_limit: [100, 100]).processed.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_limit+):
#
# avatar.variant(resize_to_limit: [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]
- 3
class ActiveStorage::Variant
- 3
attr_reader :blob, :variation
- 3
delegate :service, to: :blob
- 3
delegate :filename, :content_type, to: :specification
- 3
def initialize(blob, variation_or_variation_key)
- 88
@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.
- 3
def processed
- 67
process unless processed?
- 64
self
end
# Returns a combination key of the blob and the variation that together identifies a specific variant.
- 3
def key
- 240
"variants/#{blob.key}/#{Digest::SHA256.hexdigest(variation.key)}"
end
# Returns the URL of the blob variant on the service. See {ActiveStorage::Blob#url} for details.
#
# 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.
- 3
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline)
- 45
service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
end
- 3
alias_method :service_url, :url
- 3
deprecate service_url: :url
# Downloads the file associated with this variant. 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.
- 3
def download(&block)
- 6
service.download key, &block
end
# Returns the receiving variant. Allows ActiveStorage::Variant and ActiveStorage::Preview instances to be used interchangeably.
- 3
def image
- 5
self
end
- 3
private
- 3
def processed?
- 67
service.exist?(key)
end
- 3
def process
- 67
blob.open do |input|
- 67
variation.transform(input, format: format) do |output|
- 64
service.upload(key, output, content_type: content_type)
end
end
end
- 3
def specification
- 227
@specification ||=
- 67
if ActiveStorage.web_image_content_types.include?(blob.content_type)
- 55
Specification.new \
filename: blob.filename,
content_type: blob.content_type,
format: nil
else
- 12
Specification.new \
filename: ActiveStorage::Filename.new("#{blob.filename.base}.png"),
content_type: "image/png",
format: "png"
end
end
- 3
delegate :format, to: :specification
- 3
class Specification < OpenStruct; end
end
# frozen_string_literal: true
- 3
class ActiveStorage::VariantRecord < ActiveRecord::Base
- 3
self.table_name = "active_storage_variant_records"
- 3
belongs_to :blob
- 3
has_one_attached :image
end
# frozen_string_literal: true
- 3
class ActiveStorage::VariantWithRecord
- 3
attr_reader :blob, :variation
- 3
def initialize(blob, variation)
- 12
@blob, @variation = blob, ActiveStorage::Variation.wrap(variation)
end
- 3
def processed
process
self
end
- 3
def process
- 21
transform_blob { |image| create_or_find_record(image: image) } unless processed?
end
- 3
def processed?
- 12
record.present?
end
- 3
def image
- 12
record&.image
end
- 3
delegate :key, :url, :download, to: :image, allow_nil: true
- 3
alias_method :service_url, :url
- 3
deprecate service_url: :url
- 3
private
- 3
def transform_blob
- 9
blob.open do |input|
- 9
if blob.content_type.in?(ActiveStorage.web_image_content_types)
- 9
variation.transform(input) do |output|
- 9
yield io: output, filename: blob.filename, content_type: blob.content_type, service_name: blob.service.name
end
else
variation.transform(input, format: "png") do |output|
yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", service_name: blob.service.name
end
end
end
end
- 3
def create_or_find_record(image:)
- 9
@record =
ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do
- 9
blob.variant_records.create_or_find_by!(variation_digest: variation.digest) do |record|
- 9
record.image.attach(image)
end
end
end
- 3
def record
- 24
@record ||= blob.variant_records.find_by(variation_digest: variation.digest)
end
end
# 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_limit: [100, 100], monochrome: true, trim: true, rotate: "-90")
#
# The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands.
- 3
class ActiveStorage::Variation
- 3
attr_reader :transformations
- 3
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.
- 3
def wrap(variator)
- 128
case variator
when self
- 6
variator
when String
- 12
decode variator
else
- 110
new variator
end
end
# Returns a Variation instance with the transformations that were encoded by +encode+.
- 3
def decode(key)
- 12
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>).
- 3
def encode(transformations)
- 276
ActiveStorage.verifier.generate(transformations, purpose: :variation)
end
end
- 3
def initialize(transformations)
- 122
@transformations = transformations.deep_symbolize_keys
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.
- 3
def transform(file, format: nil, &block)
- 76
ActiveSupport::Notifications.instrument("transform.active_storage") do
- 76
transformer.transform(file, format: format, &block)
end
end
# Returns a signed key for all the +transformations+ that this variation was instantiated with.
- 3
def key
- 252
self.class.encode(transformations)
end
- 3
def digest
- 24
Digest::SHA1.base64digest Marshal.dump(transformations)
end
- 3
private
- 3
def transformer
- 76
if ActiveStorage.variant_processor
- 67
begin
- 67
require "image_processing"
rescue LoadError
ActiveSupport::Deprecation.warn <<~WARNING.squish
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
- 67
ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations)
end
else
- 9
ActiveStorage::Transformers::MiniMagickTransformer.new(transformations)
end
end
end
# frozen_string_literal: true
Rails.application.routes.draw do
- 3
scope ActiveStorage.routes_prefix do
- 3
get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
- 3
get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
- 3
get "/blobs/:signed_id/*filename" => "active_storage/blobs/redirect#show"
- 3
get "/representations/redirect/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show", as: :rails_blob_representation
- 3
get "/representations/proxy/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/proxy#show", as: :rails_blob_representation_proxy
- 3
get "/representations/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show"
- 3
get "/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service
- 3
put "/disk/:encoded_token" => "active_storage/disk#update", as: :update_rails_disk_service
- 3
post "/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads
end
- 3
direct :rails_representation do |representation, options|
signed_blob_id = representation.blob.signed_id
variation_key = representation.variation.key
filename = representation.blob.filename
route_for(:rails_blob_representation, signed_blob_id, variation_key, filename, options)
end
- 9
resolve("ActiveStorage::Variant") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
- 3
resolve("ActiveStorage::VariantWithRecord") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
- 9
resolve("ActiveStorage::Preview") { |preview, options| route_for(ActiveStorage.resolve_model_to_route, preview, options) }
- 3
direct :rails_blob do |blob, options|
route_for(:rails_service_blob, blob.signed_id, blob.filename, options)
end
- 9
resolve("ActiveStorage::Blob") { |blob, options| route_for(ActiveStorage.resolve_model_to_route, blob, options) }
- 9
resolve("ActiveStorage::Attachment") { |attachment, options| route_for(ActiveStorage.resolve_model_to_route, attachment.blob, options) }
- 3
direct :rails_storage_proxy do |model, options|
- 2
if model.respond_to?(:signed_id)
- 2
route_for(
:rails_service_blob_proxy,
model.signed_id,
model.filename,
options
)
else
signed_blob_id = model.blob.signed_id
variation_key = model.variation.key
filename = model.blob.filename
route_for(
:rails_blob_representation_proxy,
signed_blob_id,
variation_key,
filename,
options
)
end
end
- 3
direct :rails_storage_redirect do |model, options|
- 27
if model.respond_to?(:signed_id)
- 15
route_for(
:rails_service_blob,
model.signed_id,
model.filename,
options
)
else
- 12
signed_blob_id = model.blob.signed_id
- 12
variation_key = model.variation.key
- 12
filename = model.blob.filename
- 12
route_for(
:rails_blob_representation,
signed_blob_id,
variation_key,
filename,
options
)
end
end
- 3
end if ActiveStorage.draw_routes
- 3
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
- 3
def change
- 3
create_table :active_storage_blobs do |t|
- 3
t.string :key, null: false
- 3
t.string :filename, null: false
- 3
t.string :content_type
- 3
t.text :metadata
- 3
t.string :service_name, null: false
- 3
t.bigint :byte_size, null: false
- 3
t.string :checksum, null: false
- 3
t.datetime :created_at, null: false
- 3
t.index [ :key ], unique: true
end
- 3
create_table :active_storage_attachments do |t|
- 3
t.string :name, null: false
- 3
t.references :record, null: false, polymorphic: true, index: false
- 3
t.references :blob, null: false
- 3
t.datetime :created_at, null: false
- 3
t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
- 3
t.foreign_key :active_storage_blobs, column: :blob_id
end
- 3
create_table :active_storage_variant_records do |t|
- 3
t.belongs_to :blob, null: false, index: false
- 3
t.string :variation_digest, null: false
- 3
t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
- 3
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
end
# frozen_string_literal: true
#--
# Copyright (c) 2017-2020 David Heinemeier Hansson, Basecamp
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#++
- 3
require "active_record"
- 3
require "active_support"
- 3
require "active_support/rails"
- 3
require "active_support/core_ext/numeric/time"
- 3
require "active_storage/version"
- 3
require "active_storage/errors"
- 3
require "marcel"
- 3
module ActiveStorage
- 3
extend ActiveSupport::Autoload
- 3
autoload :Attached
- 3
autoload :Service
- 3
autoload :Previewer
- 3
autoload :Analyzer
- 3
mattr_accessor :logger
- 3
mattr_accessor :verifier
- 3
mattr_accessor :variant_processor, default: :mini_magick
- 3
mattr_accessor :queues, default: {}
- 3
mattr_accessor :previewers, default: []
- 3
mattr_accessor :analyzers, default: []
- 3
mattr_accessor :paths, default: {}
- 3
mattr_accessor :variable_content_types, default: []
- 3
mattr_accessor :web_image_content_types, default: []
- 3
mattr_accessor :binary_content_type, default: "application/octet-stream"
- 3
mattr_accessor :content_types_to_serve_as_binary, default: []
- 3
mattr_accessor :content_types_allowed_inline, default: []
- 3
mattr_accessor :service_urls_expire_in, default: 5.minutes
- 3
mattr_accessor :routes_prefix, default: "/rails/active_storage"
- 3
mattr_accessor :draw_routes, default: true
- 3
mattr_accessor :resolve_model_to_route, default: :rails_storage_redirect
- 3
mattr_accessor :replace_on_assign_to_many, default: false
- 3
mattr_accessor :track_variants, default: false
- 3
module Transformers
- 3
extend ActiveSupport::Autoload
- 3
autoload :Transformer
- 3
autoload :ImageProcessingTransformer
- 3
autoload :MiniMagickTransformer
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# This is an abstract base class for analyzers, which extract metadata from blobs. See
# ActiveStorage::Analyzer::ImageAnalyzer for an example of a concrete subclass.
- 3
class Analyzer
- 3
attr_reader :blob
# Implement this method in a concrete subclass. Have it return true when given a blob from which
# the analyzer can extract metadata.
- 3
def self.accept?(blob)
false
end
# Implement this method in concrete subclasses. It will determine if blob analysis
# should be done in a job or performed inline. By default, analysis is enqueued in a job.
- 3
def self.analyze_later?
- 145
true
end
- 3
def initialize(blob)
- 262
@blob = blob
end
# Override this method in a concrete subclass. Have it return a Hash of metadata.
- 3
def metadata
raise NotImplementedError
end
- 3
private
# Downloads the blob to a tempfile on disk. Yields the tempfile.
- 3
def download_blob_to_tempfile(&block) #:doc:
- 38
blob.open tmpdir: tmpdir, &block
end
- 3
def logger #:doc:
- 2
ActiveStorage.logger
end
- 3
def tmpdir #:doc:
- 38
Dir.tmpdir
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Extracts width and height in pixels from an image blob.
#
# If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience.
#
# Example:
#
# ActiveStorage::Analyzer::ImageAnalyzer.new(blob).metadata
# # => { width: 4104, height: 2736 }
#
# This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires
# the {ImageMagick}[http://www.imagemagick.org] system library.
- 3
class Analyzer::ImageAnalyzer < Analyzer
- 3
def self.accept?(blob)
- 631
blob.image?
end
- 3
def metadata
- 28
read_image do |image|
- 26
if rotated_image?(image)
- 2
{ width: image.height, height: image.width }
else
- 23
{ width: image.width, height: image.height }
end
end
end
- 3
private
- 3
def read_image
- 28
download_blob_to_tempfile do |file|
- 28
require "mini_magick"
- 28
image = MiniMagick::Image.new(file.path)
- 28
if image.valid?
- 26
yield image
else
- 2
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
- 2
{}
end
end
rescue LoadError
logger.info "Skipping image analysis because the mini_magick gem isn't installed"
{}
rescue MiniMagick::Error => error
logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
{}
end
- 3
def rotated_image?(image)
- 26
%w[ RightTop LeftBottom ].include?(image["%[orientation]"])
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Analyzer::NullAnalyzer < Analyzer # :nodoc:
- 3
def self.accept?(blob)
true
end
- 3
def self.analyze_later?
- 224
false
end
- 3
def metadata
- 224
{}
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Extracts the following from a video blob:
#
# * Width (pixels)
# * Height (pixels)
# * Duration (seconds)
# * Angle (degrees)
# * Display aspect ratio
#
# Example:
#
# ActiveStorage::Analyzer::VideoAnalyzer.new(blob).metadata
# # => { width: 640.0, height: 480.0, duration: 5.0, angle: 0, display_aspect_ratio: [4, 3] }
#
# When a video's angle is 90 or 270 degrees, its width and height are automatically swapped for convenience.
#
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails.
- 3
class Analyzer::VideoAnalyzer < Analyzer
- 3
def self.accept?(blob)
- 472
blob.video?
end
- 3
def metadata
- 10
{ width: width, height: height, duration: duration, angle: angle, display_aspect_ratio: display_aspect_ratio }.compact
end
- 3
private
- 3
def width
- 10
if rotated?
- 2
computed_height || encoded_height
else
- 8
encoded_width
end
end
- 3
def height
- 10
if rotated?
- 2
encoded_width
else
- 8
computed_height || encoded_height
end
end
- 3
def duration
- 10
Float(video_stream["duration"]) if video_stream["duration"]
end
- 3
def angle
- 46
Integer(tags["rotate"]) if tags["rotate"]
end
- 3
def display_aspect_ratio
- 36
if descriptor = video_stream["display_aspect_ratio"]
- 30
if terms = descriptor.split(":", 2)
- 30
numerator = Integer(terms[0])
- 30
denominator = Integer(terms[1])
- 30
[numerator, denominator] unless numerator == 0
end
end
end
- 3
def rotated?
- 20
angle == 90 || angle == 270
end
- 3
def computed_height
- 10
if encoded_width && display_height_scale
- 6
encoded_width * display_height_scale
end
end
- 3
def encoded_width
- 26
@encoded_width ||= Float(video_stream["width"]) if video_stream["width"]
end
- 3
def encoded_height
- 4
@encoded_height ||= Float(video_stream["height"]) if video_stream["height"]
end
- 3
def display_height_scale
- 14
@display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio
end
- 3
def tags
- 52
@tags ||= video_stream["tags"] || {}
end
- 3
def video_stream
- 118
@video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
end
- 3
def streams
- 10
probe["streams"] || []
end
- 3
def probe
- 20
download_blob_to_tempfile { |file| probe_from(file) }
end
- 3
def probe_from(file)
- 10
IO.popen([ ffprobe_path, "-print_format", "json", "-show_streams", "-v", "error", file.path ]) do |output|
- 10
JSON.parse(output.read)
end
rescue Errno::ENOENT
logger.info "Skipping video analysis because FFmpeg isn't installed"
{}
end
- 3
def ffprobe_path
- 10
ActiveStorage.paths[:ffprobe] || "ffprobe"
end
end
end
# frozen_string_literal: true
- 3
require "active_support/core_ext/module/delegation"
- 3
module ActiveStorage
# Abstract base class for the concrete ActiveStorage::Attached::One and ActiveStorage::Attached::Many
# classes that both provide proxy access to the blob association for a record.
- 3
class Attached
- 3
attr_reader :name, :record
- 3
def initialize(name, record)
- 298
@name, @record = name, record
end
- 3
private
- 3
def change
- 1793
record.attachment_changes[name]
end
end
end
- 3
require "active_storage/attached/model"
- 3
require "active_storage/attached/one"
- 3
require "active_storage/attached/many"
- 3
require "active_storage/attached/changes"
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
module Attached::Changes #:nodoc:
- 3
extend ActiveSupport::Autoload
- 3
eager_autoload do
- 3
autoload :CreateOne
- 3
autoload :CreateMany
- 3
autoload :CreateOneOfMany
- 3
autoload :DeleteOne
- 3
autoload :DeleteMany
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Attached::Changes::CreateMany #:nodoc:
- 3
attr_reader :name, :record, :attachables
- 3
def initialize(name, record, attachables)
- 133
@name, @record, @attachables = name, record, Array(attachables)
- 133
blobs.each(&:identify_without_saving)
end
- 3
def attachments
- 377
@attachments ||= subchanges.collect(&:attachment)
end
- 3
def blobs
- 133
@blobs ||= subchanges.collect(&:blob)
end
- 3
def upload
- 119
subchanges.each(&:upload)
end
- 3
def save
- 119
assign_associated_attachments
- 119
reset_associated_blobs
end
- 3
private
- 3
def subchanges
- 612
@subchanges ||= attachables.collect { |attachable| build_subchange_from(attachable) }
end
- 3
def build_subchange_from(attachable)
- 233
ActiveStorage::Attached::Changes::CreateOneOfMany.new(name, record, attachable)
end
- 3
def assign_associated_attachments
- 119
record.public_send("#{name}_attachments=", attachments)
end
- 3
def reset_associated_blobs
- 119
record.public_send("#{name}_blobs").reset
end
end
end
# frozen_string_literal: true
- 3
require "action_dispatch"
- 3
require "action_dispatch/http/upload"
- 3
module ActiveStorage
- 3
class Attached::Changes::CreateOne #:nodoc:
- 3
attr_reader :name, :record, :attachable
- 3
def initialize(name, record, attachable)
- 425
@name, @record, @attachable = name, record, attachable
- 425
blob.identify_without_saving
end
- 3
def attachment
- 523
@attachment ||= find_or_build_attachment
end
- 3
def blob
- 1607
@blob ||= find_or_build_blob
end
- 3
def upload
- 393
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
- 53
blob.upload_without_unfurling(attachable.open)
when Hash
- 65
blob.upload_without_unfurling(attachable.fetch(:io))
end
end
- 3
def save
- 180
record.public_send("#{name}_attachment=", attachment)
- 180
record.public_send("#{name}_blob=", blob)
end
- 3
private
- 3
def find_or_build_attachment
- 415
find_attachment || build_attachment
end
- 3
def find_attachment
- 188
if record.public_send("#{name}_blob") == blob
- 4
record.public_send("#{name}_attachment")
end
end
- 3
def build_attachment
- 403
ActiveStorage::Attachment.new(record: record, name: name, blob: blob)
end
- 3
def find_or_build_blob
- 425
case attachable
when ActiveStorage::Blob
- 259
attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
- 67
ActiveStorage::Blob.build_after_unfurling(
io: attachable.open,
filename: attachable.original_filename,
content_type: attachable.content_type,
record: record,
service_name: attachment_service_name
)
when Hash
- 65
ActiveStorage::Blob.build_after_unfurling(
**attachable.reverse_merge(
record: record,
service_name: attachment_service_name
).symbolize_keys
)
when String
- 30
ActiveStorage::Blob.find_signed!(attachable, record: record)
else
- 4
raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
end
end
- 3
def attachment_service_name
- 132
record.attachment_reflections[name].options[:service_name]
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Attached::Changes::CreateOneOfMany < Attached::Changes::CreateOne #:nodoc:
- 3
private
- 3
def find_attachment
- 289
record.public_send("#{name}_attachments").detect { |attachment| attachment.blob_id == blob.id }
end
end
end
# frozen_string_literal: true
- 2
module ActiveStorage
- 2
class Attached::Changes::DeleteMany #:nodoc:
- 2
attr_reader :name, :record
- 2
def initialize(name, record)
- 8
@name, @record = name, record
end
- 2
def attachables
[]
end
- 2
def attachments
- 4
ActiveStorage::Attachment.none
end
- 2
def blobs
- 4
ActiveStorage::Blob.none
end
- 2
def save
- 4
record.public_send("#{name}_attachments=", [])
end
end
end
# frozen_string_literal: true
- 2
module ActiveStorage
- 2
class Attached::Changes::DeleteOne #:nodoc:
- 2
attr_reader :name, :record
- 2
def initialize(name, record)
- 8
@name, @record = name, record
end
- 2
def attachment
nil
end
- 2
def save
- 8
record.public_send("#{name}_attachment=", nil)
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Decorated proxy object representing of multiple attachments to a model.
- 3
class Attached::Many < Attached
- 3
delegate_missing_to :attachments
# Returns all the associated attachment records.
#
# All methods called on this proxy object that aren't listed here will automatically be delegated to +attachments+.
- 3
def attachments
- 652
change.present? ? change.attachments : record.public_send("#{name}_attachments")
end
# Returns all attached blobs.
- 3
def blobs
- 107
change.present? ? change.blobs : record.public_send("#{name}_blobs")
end
# Attaches one or more +attachables+ to the record.
#
# If the record is persisted and unchanged, the attachments are saved to
# the database immediately. Otherwise, they'll be saved to the DB when the
# record is next saved.
#
# document.images.attach(params[:images]) # Array of ActionDispatch::Http::UploadedFile objects
# document.images.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
# document.images.attach(io: File.open("/path/to/racecar.jpg"), filename: "racecar.jpg", content_type: "image/jpg")
# document.images.attach([ first_blob, second_blob ])
- 3
def attach(*attachables)
- 105
if record.persisted? && !record.changed?
- 81
record.public_send("#{name}=", blobs + attachables.flatten)
- 81
record.save
else
- 24
record.public_send("#{name}=", (change&.attachables || blobs) + attachables.flatten)
end
end
# Returns true if any attachments have been made.
#
# class Gallery < ApplicationRecord
# has_many_attached :photos
# end
#
# Gallery.new.photos.attached? # => false
- 3
def attached?
- 38
attachments.any?
end
# Deletes associated attachments without purging them, leaving their respective blobs in place.
- 3
def detach
- 2
attachments.delete_all if attached?
end
##
# :method: purge
#
# Directly purges each associated attachment (i.e. destroys the blobs and
# attachments and deletes the files on the service).
##
# :method: purge_later
#
# Purges each associated attachment through the queuing system.
end
end
# frozen_string_literal: true
- 3
require "active_support/core_ext/object/try"
- 3
module ActiveStorage
# Provides the class-level DSL for declaring an Active Record model's attachments.
- 3
module Attached::Model
- 3
extend ActiveSupport::Concern
- 3
class_methods do
# Specifies the relation between a single attachment and the model.
#
# class User < ApplicationRecord
# has_one_attached :avatar
# end
#
# There is no column defined on the model side, Active Storage takes
# care of the mapping between your records and the attachment.
#
# To avoid N+1 queries, you can include the attached blobs in your query like so:
#
# User.with_attached_avatar
#
# Under the covers, this relationship is implemented as a +has_one+ association to a
# ActiveStorage::Attachment record and a +has_one-through+ association to a
# ActiveStorage::Blob record. These associations are available as +avatar_attachment+
# and +avatar_blob+. But you shouldn't need to work with these associations directly in
# most circumstances.
#
# The system has been designed to having you go through the ActiveStorage::Attached::One
# proxy that provides the dynamic proxy to the associations and factory methods, like +attach+.
#
# If the +:dependent+ option isn't set, the attachment will be purged
# (i.e. destroyed) whenever the record is destroyed.
#
# If you need the attachment to use a service which differs from the globally configured one,
# pass the +:service+ option. For instance:
#
# class User < ActiveRecord::Base
# has_one_attached :avatar, service: :s3
# end
#
- 3
def has_one_attached(name, dependent: :purge_later, service: nil)
- 17
validate_service_configuration(name, service)
- 15
generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
# frozen_string_literal: true
def #{name}
@active_storage_attached ||= {}
@active_storage_attached[:#{name}] ||= ActiveStorage::Attached::One.new("#{name}", self)
end
def #{name}=(attachable)
attachment_changes["#{name}"] =
if attachable.nil?
ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
else
ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
end
end
CODE
- 457
has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy
- 15
has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob
- 15
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
- 2047
after_save { attachment_changes[name.to_s]&.save }
- 2102
after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) }
- 15
reflection = ActiveRecord::Reflection.create(
:has_one_attached,
name,
nil,
{ dependent: dependent, service_name: service },
self
)
- 15
ActiveRecord::Reflection.add_attachment_reflection(self, name, reflection)
end
# Specifies the relation between multiple attachments and the model.
#
# class Gallery < ApplicationRecord
# has_many_attached :photos
# end
#
# There are no columns defined on the model side, Active Storage takes
# care of the mapping between your records and the attachments.
#
# To avoid N+1 queries, you can include the attached blobs in your query like so:
#
# Gallery.where(user: Current.user).with_attached_photos
#
# Under the covers, this relationship is implemented as a +has_many+ association to a
# ActiveStorage::Attachment record and a +has_many-through+ association to a
# ActiveStorage::Blob record. These associations are available as +photos_attachments+
# and +photos_blobs+. But you shouldn't need to work with these associations directly in
# most circumstances.
#
# The system has been designed to having you go through the ActiveStorage::Attached::Many
# proxy that provides the dynamic proxy to the associations and factory methods, like +#attach+.
#
# If the +:dependent+ option isn't set, all the attachments will be purged
# (i.e. destroyed) whenever the record is destroyed.
#
# If you need the attachment to use a service which differs from the globally configured one,
# pass the +:service+ option. For instance:
#
# class Gallery < ActiveRecord::Base
# has_many_attached :photos, service: :s3
# end
#
- 3
def has_many_attached(name, dependent: :purge_later, service: nil)
- 8
validate_service_configuration(name, service)
- 6
generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
# frozen_string_literal: true
def #{name}
@active_storage_attached ||= {}
@active_storage_attached[:#{name}] ||= ActiveStorage::Attached::Many.new("#{name}", self)
end
def #{name}=(attachables)
if ActiveStorage.replace_on_assign_to_many
attachment_changes["#{name}"] =
if Array(attachables).none?
ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self)
else
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables)
end
else
if Array(attachables).any?
attachment_changes["#{name}"] =
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, #{name}.blobs + attachables)
end
end
end
CODE
- 439
has_many :"#{name}_attachments", -> { where(name: name) }, as: :record, class_name: "ActiveStorage::Attachment", inverse_of: :record, dependent: :destroy do
- 6
def purge
- 4
each(&:purge)
- 4
reset
end
- 6
def purge_later
- 4
each(&:purge_later)
- 4
reset
end
end
- 6
has_many :"#{name}_blobs", through: :"#{name}_attachments", class_name: "ActiveStorage::Blob", source: :blob
- 6
scope :"with_attached_#{name}", -> { includes("#{name}_attachments": :blob) }
- 1060
after_save { attachment_changes[name.to_s]&.save }
- 1116
after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) }
- 6
reflection = ActiveRecord::Reflection.create(
:has_many_attached,
name,
nil,
{ dependent: dependent, service_name: service },
self
)
- 6
ActiveRecord::Reflection.add_attachment_reflection(self, name, reflection)
end
- 3
private
- 3
def validate_service_configuration(association_name, service)
- 25
if service.present?
- 10
ActiveStorage::Blob.services.fetch(service) do
- 4
raise ArgumentError, "Cannot configure service :#{service} for #{name}##{association_name}"
end
end
end
end
- 3
def attachment_changes #:nodoc:
- 9281
@attachment_changes ||= {}
end
- 3
def changed_for_autosave? #:nodoc:
- 1020
super || attachment_changes.any?
end
- 3
def initialize_dup(*) #:nodoc:
- 8
super
- 8
@active_storage_attached = nil
- 8
@attachment_changes = nil
end
- 3
def reload(*) #:nodoc:
- 186
super.tap { @attachment_changes = nil }
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Representation of a single attachment to a model.
- 3
class Attached::One < Attached
- 3
delegate_missing_to :attachment, allow_nil: true
# Returns the associated attachment record.
#
# You don't have to call this method to access the attachment's methods as
# they are all available at the model level.
- 3
def attachment
- 628
change.present? ? change.attachment : record.public_send("#{name}_attachment")
end
- 3
def blank?
- 8
!attached?
end
# Attaches an +attachable+ to the record.
#
# If the record is persisted and unchanged, the attachment is saved to
# the database immediately. Otherwise, it'll be saved to the DB when the
# record is next saved.
#
# person.avatar.attach(params[:avatar]) # ActionDispatch::Http::UploadedFile object
# person.avatar.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
# person.avatar.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpg")
# person.avatar.attach(avatar_blob) # ActiveStorage::Blob object
- 3
def attach(attachable)
- 150
if record.persisted? && !record.changed?
- 120
record.public_send("#{name}=", attachable)
- 120
record.save
else
- 30
record.public_send("#{name}=", attachable)
end
end
# Returns +true+ if an attachment has been made.
#
# class User < ApplicationRecord
# has_one_attached :avatar
# end
#
# User.new.avatar.attached? # => false
- 3
def attached?
- 107
attachment.present?
end
# Deletes the attachment without purging it, leaving its blob in place.
- 3
def detach
- 2
if attached?
- 2
attachment.delete
- 2
write_attachment nil
end
end
# Directly purges the attachment (i.e. destroys the blob and
# attachment and deletes the file on the service).
- 3
def purge
- 4
if attached?
- 4
attachment.purge
- 4
write_attachment nil
end
end
# Purges the attachment through the queuing system.
- 3
def purge_later
- 4
if attached?
- 4
attachment.purge_later
- 4
write_attachment nil
end
end
- 3
private
- 3
def write_attachment(attachment)
- 10
record.public_send("#{name}_attachment=", attachment)
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Downloader #:nodoc:
- 3
attr_reader :service
- 3
def initialize(service)
- 159
@service = service
end
- 3
def open(key, checksum:, name: "ActiveStorage-", tmpdir: nil)
- 159
open_tempfile(name, tmpdir) do |file|
- 159
download key, file
- 159
verify_integrity_of file, checksum: checksum
- 157
yield file
end
end
- 3
private
- 3
def open_tempfile(name, tmpdir = nil)
- 159
file = Tempfile.open(name, tmpdir)
- 159
begin
- 159
yield file
ensure
- 159
file.close!
end
end
- 3
def download(key, file)
- 159
file.binmode
- 318
service.download(key) { |chunk| file.write(chunk) }
- 159
file.flush
- 159
file.rewind
end
- 3
def verify_integrity_of(file, checksum:)
- 159
unless Digest::MD5.file(file).base64digest == checksum
- 2
raise ActiveStorage::IntegrityError
end
end
end
end
# frozen_string_literal: true
require "tmpdir"
require "active_support/core_ext/string/filters"
module ActiveStorage
module Downloading
def self.included(klass)
ActiveSupport::Deprecation.warn <<~MESSAGE.squish, caller_locations(2)
ActiveStorage::Downloading is deprecated and will be removed in Active Storage 6.1.
Use ActiveStorage::Blob#open instead.
MESSAGE
end
private
# Opens a new tempfile in #tempdir and copies blob data into it. Yields the tempfile.
def download_blob_to_tempfile #:doc:
open_tempfile_for_blob do |file|
download_blob_to file
yield file
end
end
def open_tempfile_for_blob
tempfile = Tempfile.open([ "ActiveStorage", blob.filename.extension_with_delimiter ], tempdir)
begin
yield tempfile
ensure
tempfile.close!
end
end
# Efficiently downloads blob data into the given file.
def download_blob_to(file) #:doc:
file.binmode
blob.download { |chunk| file.write(chunk) }
file.flush
file.rewind
end
# Returns the directory in which tempfiles should be opened. Defaults to +Dir.tmpdir+.
def tempdir #:doc:
Dir.tmpdir
end
end
end
# frozen_string_literal: true
- 3
require "rails"
- 3
require "action_controller/railtie"
- 3
require "active_job/railtie"
- 3
require "active_record/railtie"
- 3
require "active_storage"
- 3
require "active_storage/previewer/poppler_pdf_previewer"
- 3
require "active_storage/previewer/mupdf_previewer"
- 3
require "active_storage/previewer/video_previewer"
- 3
require "active_storage/analyzer/image_analyzer"
- 3
require "active_storage/analyzer/video_analyzer"
- 3
require "active_storage/service/registry"
- 3
require "active_storage/reflection"
- 3
module ActiveStorage
- 3
class Engine < Rails::Engine # :nodoc:
- 3
isolate_namespace ActiveStorage
- 3
config.active_storage = ActiveSupport::OrderedOptions.new
- 3
config.active_storage.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
- 3
config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ]
- 3
config.active_storage.paths = ActiveSupport::OrderedOptions.new
- 3
config.active_storage.queues = ActiveSupport::InheritableOptions.new(mirror: :active_storage_mirror)
- 3
config.active_storage.variable_content_types = %w(
image/png
image/gif
image/jpg
image/jpeg
image/pjpeg
image/tiff
image/bmp
image/vnd.adobe.photoshop
image/vnd.microsoft.icon
image/webp
)
- 3
config.active_storage.web_image_content_types = %w(
image/png
image/jpeg
image/jpg
image/gif
)
- 3
config.active_storage.content_types_to_serve_as_binary = %w(
text/html
text/javascript
image/svg+xml
application/postscript
application/x-shockwave-flash
text/xml
application/xml
application/xhtml+xml
application/mathml+xml
text/cache-manifest
)
- 3
config.active_storage.content_types_allowed_inline = %w(
image/png
image/gif
image/jpg
image/jpeg
image/tiff
image/bmp
image/vnd.adobe.photoshop
image/vnd.microsoft.icon
application/pdf
)
- 3
config.eager_load_namespaces << ActiveStorage
- 3
initializer "active_storage.configs" do
- 3
config.after_initialize do |app|
- 3
ActiveStorage.logger = app.config.active_storage.logger || Rails.logger
- 3
ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick
- 3
ActiveStorage.previewers = app.config.active_storage.previewers || []
- 3
ActiveStorage.analyzers = app.config.active_storage.analyzers || []
- 3
ActiveStorage.paths = app.config.active_storage.paths || {}
- 3
ActiveStorage.routes_prefix = app.config.active_storage.routes_prefix || "/rails/active_storage"
- 3
ActiveStorage.draw_routes = app.config.active_storage.draw_routes != false
- 3
ActiveStorage.resolve_model_to_route = app.config.active_storage.resolve_model_to_route || :rails_storage_redirect
- 3
ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || []
- 3
ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || []
- 3
ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || []
- 3
ActiveStorage.service_urls_expire_in = app.config.active_storage.service_urls_expire_in || 5.minutes
- 3
ActiveStorage.content_types_allowed_inline = app.config.active_storage.content_types_allowed_inline || []
- 3
ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream"
- 3
ActiveStorage.replace_on_assign_to_many = app.config.active_storage.replace_on_assign_to_many || false
- 3
ActiveStorage.track_variants = app.config.active_storage.track_variants || false
end
end
- 3
initializer "active_storage.attached" do
- 3
require "active_storage/attached"
- 3
ActiveSupport.on_load(:active_record) do
- 3
include ActiveStorage::Attached::Model
end
end
- 3
initializer "active_storage.verifier" do
- 3
config.after_initialize do |app|
- 3
ActiveStorage.verifier = app.message_verifier("ActiveStorage")
end
end
- 3
initializer "active_storage.services" do
- 3
ActiveSupport.on_load(:active_storage_blob) do
- 3
configs = Rails.configuration.active_storage.service_configurations ||=
begin
config_file = Rails.root.join("config/storage.yml")
raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist?
ActiveSupport::ConfigurationFile.parse(config_file)
end
- 3
ActiveStorage::Blob.services = ActiveStorage::Service::Registry.new(configs)
- 3
if config_choice = Rails.configuration.active_storage.service
- 3
ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(config_choice)
end
end
end
- 3
initializer "active_storage.queues" do
- 3
config.after_initialize do |app|
- 3
if queue = app.config.active_storage.queue
ActiveSupport::Deprecation.warn \
"config.active_storage.queue is deprecated and will be removed in Rails 6.1. " \
"Set config.active_storage.queues.purge and config.active_storage.queues.analysis instead."
ActiveStorage.queues = { purge: queue, analysis: queue, mirror: queue }
else
- 3
ActiveStorage.queues = app.config.active_storage.queues || {}
end
end
end
- 3
initializer "active_storage.reflection" do
- 3
ActiveSupport.on_load(:active_record) do
- 3
include Reflection::ActiveRecordExtensions
- 3
ActiveRecord::Reflection.singleton_class.prepend(Reflection::ReflectionExtension)
end
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Generic base class for all Active Storage exceptions.
- 3
class Error < StandardError; end
# Raised when ActiveStorage::Blob#variant is called on a blob that isn't variable.
# Use ActiveStorage::Blob#variable? to determine whether a blob is variable.
- 3
class InvariableError < Error; end
# Raised when ActiveStorage::Blob#preview is called on a blob that isn't previewable.
# Use ActiveStorage::Blob#previewable? to determine whether a blob is previewable.
- 3
class UnpreviewableError < Error; end
# Raised when ActiveStorage::Blob#representation is called on a blob that isn't representable.
# Use ActiveStorage::Blob#representable? to determine whether a blob is representable.
- 3
class UnrepresentableError < Error; end
# Raised when uploaded or downloaded data does not match a precomputed checksum.
# Indicates that a network error or a software bug caused data corruption.
- 3
class IntegrityError < Error; end
# Raised when ActiveStorage::Blob#download is called on a blob where the
# backing file is no longer present in its service.
- 3
class FileNotFoundError < Error; end
end
# frozen_string_literal: true
- 3
module ActiveStorage
# Returns the version of the currently loaded Active Storage as a <tt>Gem::Version</tt>.
- 3
def self.gem_version
Gem::Version.new VERSION::STRING
end
- 3
module VERSION
- 3
MAJOR = 6
- 3
MINOR = 1
- 3
TINY = 0
- 3
PRE = "alpha"
- 3
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end
end
# frozen_string_literal: true
- 3
require "active_support/log_subscriber"
- 3
module ActiveStorage
- 3
class LogSubscriber < ActiveSupport::LogSubscriber
- 3
def service_upload(event)
- 1163
message = "Uploaded file to key: #{key_in(event)}"
- 1163
message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
- 1163
info event, color(message, GREEN)
end
- 3
def service_download(event)
- 251
info event, color("Downloaded file from key: #{key_in(event)}", BLUE)
end
- 3
alias_method :service_streaming_download, :service_download
- 3
def service_delete(event)
- 859
info event, color("Deleted file from key: #{key_in(event)}", RED)
end
- 3
def service_delete_prefixed(event)
- 147
info event, color("Deleted files by key prefix: #{event.payload[:prefix]}", RED)
end
- 3
def service_exist(event)
- 287
debug event, color("Checked if file exists at key: #{key_in(event)} (#{event.payload[:exist] ? "yes" : "no"})", BLUE)
end
- 3
def service_url(event)
- 120
debug event, color("Generated URL for file at key: #{key_in(event)} (#{event.payload[:url]})", BLUE)
end
- 3
def service_mirror(event)
- 6
message = "Mirrored file at key: #{key_in(event)}"
- 6
message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
- 6
debug event, color(message, GREEN)
end
- 3
def logger
- 11332
ActiveStorage.logger
end
- 3
private
- 3
def info(event, colored_message)
- 2420
super log_prefix_for_service(event) + colored_message
end
- 3
def debug(event, colored_message)
- 413
super log_prefix_for_service(event) + colored_message
end
- 3
def log_prefix_for_service(event)
- 2833
color " #{event.payload[:service]} Storage (#{event.duration.round(1)}ms) ", CYAN
end
- 3
def key_in(event)
- 2686
event.payload[:key]
end
end
end
- 3
ActiveStorage::LogSubscriber.attach_to :active_storage
# frozen_string_literal: true
- 3
module ActiveStorage
# This is an abstract base class for previewers, which generate images from blobs. See
# ActiveStorage::Previewer::MuPDFPreviewer and ActiveStorage::Previewer::VideoPreviewer for
# examples of concrete subclasses.
- 3
class Previewer
- 3
attr_reader :blob
# Implement this method in a concrete subclass. Have it return true when given a blob from which
# the previewer can generate an image.
- 3
def self.accept?(blob)
false
end
- 3
def initialize(blob)
- 33
@blob = blob
end
# Override this method in a concrete subclass. Have it yield an attachable preview image (i.e.
# anything accepted by ActiveStorage::Attached::One#attach). Pass the additional options to
# the underlying blob that is created.
- 3
def preview(**options)
raise NotImplementedError
end
- 3
private
# Downloads the blob to a tempfile on disk. Yields the tempfile.
- 3
def download_blob_to_tempfile(&block) #:doc:
- 33
blob.open tmpdir: tmpdir, &block
end
# Executes a system command, capturing its binary output in a tempfile. Yields the tempfile.
#
# Use this method to shell out to a system library (e.g. muPDF or FFmpeg) for preview image
# generation. The resulting tempfile can be used as the +:io+ value in an attachable Hash:
#
# def preview
# download_blob_to_tempfile do |input|
# draw "my-drawing-command", input.path, "--format", "png", "-" do |output|
# yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png"
# end
# end
# end
#
# The output tempfile is opened in the directory returned by #tmpdir.
- 3
def draw(*argv) #:doc:
- 33
open_tempfile do |file|
- 33
instrument :preview, key: blob.key do
- 33
capture(*argv, to: file)
end
- 32
yield file
end
end
- 3
def open_tempfile
- 33
tempfile = Tempfile.open("ActiveStorage-", tmpdir)
- 33
begin
- 33
yield tempfile
ensure
- 33
tempfile.close!
end
end
- 3
def instrument(operation, payload = {}, &block)
- 33
ActiveSupport::Notifications.instrument "#{operation}.active_storage", payload, &block
end
- 3
def capture(*argv, to:)
- 33
to.binmode
- 65
IO.popen(argv, err: File::NULL) { |out| IO.copy_stream(out, to) }
- 32
to.rewind
end
- 3
def logger #:doc:
ActiveStorage.logger
end
- 3
def tmpdir #:doc:
- 66
Dir.tmpdir
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Previewer::MuPDFPreviewer < Previewer
- 3
class << self
- 3
def accept?(blob)
- 31
blob.content_type == "application/pdf" && mutool_exists?
end
- 3
def mutool_path
- 3
ActiveStorage.paths[:mutool] || "mutool"
end
- 3
def mutool_exists?
return @mutool_exists unless @mutool_exists.nil?
system mutool_path, out: File::NULL, err: File::NULL
@mutool_exists = $?.exitstatus == 1
end
end
- 3
def preview(**options)
- 3
download_blob_to_tempfile do |input|
- 3
draw_first_page_from input do |output|
- 2
yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", **options
end
end
end
- 3
private
- 3
def draw_first_page_from(file, &block)
- 3
draw self.class.mutool_path, "draw", "-F", "png", "-o", "-", file.path, "1", &block
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Previewer::PopplerPDFPreviewer < Previewer
- 3
class << self
- 3
def accept?(blob)
- 76
blob.content_type == "application/pdf" && pdftoppm_exists?
end
- 3
def pdftoppm_path
- 23
ActiveStorage.paths[:pdftoppm] || "pdftoppm"
end
- 3
def pdftoppm_exists?
- 45
return @pdftoppm_exists if defined?(@pdftoppm_exists)
- 3
@pdftoppm_exists = system(pdftoppm_path, "-v", out: File::NULL, err: File::NULL)
end
end
- 3
def preview(**options)
- 20
download_blob_to_tempfile do |input|
- 20
draw_first_page_from input do |output|
- 20
yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", **options
end
end
end
- 3
private
- 3
def draw_first_page_from(file, &block)
# use 72 dpi to match thumbnail dimensions of the PDF
- 20
draw self.class.pdftoppm_path, "-singlefile", "-r", "72", "-png", file.path, &block
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Previewer::VideoPreviewer < Previewer
- 3
class << self
- 3
def accept?(blob)
- 31
blob.video? && ffmpeg_exists?
end
- 3
def ffmpeg_exists?
- 18
return @ffmpeg_exists if defined?(@ffmpeg_exists)
- 3
@ffmpeg_exists = system(ffmpeg_path, "-version", out: File::NULL, err: File::NULL)
end
- 3
def ffmpeg_path
- 13
ActiveStorage.paths[:ffmpeg] || "ffmpeg"
end
end
- 3
def preview(**options)
- 10
download_blob_to_tempfile do |input|
- 10
draw_relevant_frame_from input do |output|
- 10
yield io: output, filename: "#{blob.filename.base}.jpg", content_type: "image/jpeg", **options
end
end
end
- 3
private
- 3
def draw_relevant_frame_from(file, &block)
- 10
draw self.class.ffmpeg_path, "-i", file.path, "-y", "-vframes", "1", "-f", "image2", "-", &block
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
module Reflection
# Holds all the metadata about a has_one_attached attachment as it was
# specified in the Active Record class.
- 3
class HasOneAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc:
- 3
def macro
- 9
:has_one_attached
end
end
# Holds all the metadata about a has_many_attached attachment as it was
# specified in the Active Record class.
- 3
class HasManyAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc:
- 3
def macro
- 9
:has_many_attached
end
end
- 3
module ReflectionExtension # :nodoc:
- 3
def add_attachment_reflection(model, name, reflection)
- 21
model.attachment_reflections = model.attachment_reflections.merge(name.to_s => reflection)
end
- 3
private
- 3
def reflection_class_for(macro)
- 81
case macro
when :has_one_attached
- 15
HasOneAttachedReflection
when :has_many_attached
- 6
HasManyAttachedReflection
else
- 60
super
end
end
end
- 3
module ActiveRecordExtensions
- 3
extend ActiveSupport::Concern
- 3
included do
- 3
class_attribute :attachment_reflections, instance_writer: false, default: {}
end
- 3
module ClassMethods
# Returns an array of reflection objects for all the attachments in the
# class.
- 3
def reflect_on_all_attachments
- 3
attachment_reflections.values
end
# Returns the reflection object for the named +attachment+.
#
# User.reflect_on_attachment(:avatar)
# # => the avatar reflection
#
- 3
def reflect_on_attachment(attachment)
- 15
attachment_reflections[attachment.to_s]
end
end
end
end
end
# frozen_string_literal: true
- 3
require "active_storage/log_subscriber"
- 3
require "active_storage/downloader"
- 3
require "action_dispatch"
- 3
require "action_dispatch/http/content_disposition"
- 3
module ActiveStorage
# Abstract class serving as an interface for concrete services.
#
# The available services are:
#
# * +Disk+, to manage attachments saved directly on the hard drive.
# * +GCS+, to manage attachments through Google Cloud Storage.
# * +S3+, to manage attachments through Amazon S3.
# * +AzureStorage+, to manage attachments through Microsoft Azure Storage.
# * +Mirror+, to be able to use several services to manage attachments.
#
# Inside a Rails application, you can set-up your services through the
# generated <tt>config/storage.yml</tt> file and reference one
# of the aforementioned constant under the +service+ key. For example:
#
# local:
# service: Disk
# root: <%= Rails.root.join("storage") %>
#
# You can checkout the service's constructor to know which keys are required.
#
# Then, in your application's configuration, you can specify the service to
# use like this:
#
# config.active_storage.service = :local
#
# If you are using Active Storage outside of a Ruby on Rails application, you
# can configure the service to use like this:
#
# ActiveStorage::Blob.service = ActiveStorage::Service.configure(
# :Disk,
# root: Pathname("/foo/bar/storage")
# )
- 3
class Service
- 3
extend ActiveSupport::Autoload
- 3
autoload :Configurator
- 3
attr_accessor :name
- 3
class << self
# Configure an Active Storage service by name from a set of configurations,
# typically loaded from a YAML file. The Active Storage engine uses this
# to set the global Active Storage service when the app boots.
- 3
def configure(service_name, configurations)
- 9
Configurator.build(service_name, configurations)
end
# Override in subclasses that stitch together multiple services and hence
# need to build additional services using the configurator.
#
# Passes the configurator and all of the service's config as keyword args.
#
# See MirrorService for an example.
- 3
def build(configurator:, name:, service: nil, **service_config) #:nodoc:
- 42
new(**service_config).tap do |service_instance|
- 42
service_instance.name = name
end
end
end
# Upload the +io+ to the +key+ specified. If a +checksum+ is provided, the service will
# ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
- 3
def upload(key, io, checksum: nil, **options)
raise NotImplementedError
end
# Update metadata for the file identified by +key+ in the service.
# Override in subclasses only if the service needs to store specific
# metadata that has to be updated upon identification.
- 3
def update_metadata(key, **metadata)
end
# Return the content of the file at the +key+.
- 3
def download(key)
raise NotImplementedError
end
# Return the partial content in the byte +range+ of the file at the +key+.
- 3
def download_chunk(key, range)
raise NotImplementedError
end
- 3
def open(*args, **options, &block)
- 159
ActiveStorage::Downloader.new(self).open(*args, **options, &block)
end
# Delete the file at the +key+.
- 3
def delete(key)
raise NotImplementedError
end
# Delete files at keys starting with the +prefix+.
- 3
def delete_prefixed(prefix)
raise NotImplementedError
end
# Return +true+ if a file exists at the +key+.
- 3
def exist?(key)
raise NotImplementedError
end
# Returns the URL for the file at the +key+. This returns a permanent URL for public files, and returns a
# short-lived URL for private files. You must provide the +disposition+ (+:inline+ or +:attachment+),
# +filename+, and +content_type+ that you wish the file to be served with on request. In addition, for
# private files, you must also provide the amount of seconds the URL will be valid for, specified in +expires_in+.
- 3
def url(key, **options)
- 99
instrument :url, key: key do |payload|
- 99
generated_url =
- 99
if public?
- 8
public_url(key, **options)
else
- 91
private_url(key, **options)
end
- 99
payload[:url] = generated_url
- 99
generated_url
end
end
# Returns a signed, temporary URL that a direct upload file can be PUT to on the +key+.
# The URL will be valid for the amount of seconds specified in +expires_in+.
# You must also provide the +content_type+, +content_length+, and +checksum+ of the file
# that will be uploaded. All these attributes will be validated by the service upon upload.
- 3
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
raise NotImplementedError
end
# Returns a Hash of headers for +url_for_direct_upload+ requests.
- 3
def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
{}
end
- 3
def public?
- 99
@public
end
- 3
private
- 3
def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
raise NotImplementedError
end
- 3
def public_url(key, **)
raise NotImplementedError
end
- 3
def instrument(operation, payload = {}, &block)
- 2873
ActiveSupport::Notifications.instrument(
"service_#{operation}.active_storage",
payload.merge(service: service_name), &block)
end
- 3
def service_name
# ActiveStorage::Service::DiskService => Disk
- 2873
self.class.name.split("::").third.remove("Service")
end
- 3
def content_disposition_with(type: "inline", filename:)
- 99
disposition = (type.to_s.presence_in(%w( attachment inline )) || "inline")
- 99
ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: filename.sanitized)
end
end
end
# frozen_string_literal: true
gem "azure-storage-blob", ">= 1.1"
require "active_support/core_ext/numeric/bytes"
require "azure/storage/blob"
require "azure/storage/common/core/auth/shared_access_signature"
module ActiveStorage
# Wraps the Microsoft Azure Storage Blob Service as an Active Storage service.
# See ActiveStorage::Service for the generic API documentation that applies to all services.
class Service::AzureStorageService < Service
attr_reader :client, :container, :signer
def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options)
@client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options)
@signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
@container = container
@public = public
end
def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
instrument :upload, key: key, checksum: checksum do
handle_errors do
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition)
end
end
end
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream(key, &block)
end
else
instrument :download, key: key do
handle_errors do
_, io = client.get_blob(container, key)
io.force_encoding(Encoding::BINARY)
end
end
end
end
def download_chunk(key, range)
instrument :download_chunk, key: key, range: range do
handle_errors do
_, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end)
io.force_encoding(Encoding::BINARY)
end
end
end
def delete(key)
instrument :delete, key: key do
client.delete_blob(container, key)
rescue Azure::Core::Http::HTTPError => e
raise unless e.type == "BlobNotFound"
# Ignore files already deleted
end
end
def delete_prefixed(prefix)
instrument :delete_prefixed, prefix: prefix do
marker = nil
loop do
results = client.list_blobs(container, prefix: prefix, marker: marker)
results.each do |blob|
client.delete_blob(container, blob.name)
end
break unless marker = results.continuation_token.presence
end
end
end
def exist?(key)
instrument :exist, key: key do |payload|
answer = blob_for(key).present?
payload[:exist] = answer
answer
end
end
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
instrument :url, key: key do |payload|
generated_url = signer.signed_uri(
uri_for(key), false,
service: "b",
permissions: "rw",
expiry: format_expiry(expires_in)
).to_s
payload[:url] = generated_url
generated_url
end
end
def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
{ "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob" }
end
private
def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
signer.signed_uri(
uri_for(key), false,
service: "b",
permissions: "r",
expiry: format_expiry(expires_in),
content_disposition: content_disposition_with(type: disposition, filename: filename),
content_type: content_type
).to_s
end
def public_url(key, **)
uri_for(key).to_s
end
def uri_for(key)
client.generate_uri("#{container}/#{key}")
end
def blob_for(key)
client.get_blob_properties(container, key)
rescue Azure::Core::Http::HTTPError
false
end
def format_expiry(expires_in)
expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil
end
# Reads the object for the given key in chunks, yielding each to the block.
def stream(key)
blob = blob_for(key)
chunk_size = 5.megabytes
offset = 0
raise ActiveStorage::FileNotFoundError unless blob.present?
while offset < blob.properties[:content_length]
_, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1)
yield chunk.force_encoding(Encoding::BINARY)
offset += chunk_size
end
end
def handle_errors
yield
rescue Azure::Core::Http::HTTPError => e
case e.type
when "BlobNotFound"
raise ActiveStorage::FileNotFoundError
when "Md5Mismatch"
raise ActiveStorage::IntegrityError
else
raise
end
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Service::Configurator #:nodoc:
- 3
attr_reader :configurations
- 3
def self.build(service_name, configurations)
- 18
new(configurations).build(service_name)
end
- 3
def initialize(configurations)
- 21
@configurations = configurations.deep_symbolize_keys
end
- 3
def build(service_name)
- 51
config = config_for(service_name.to_sym)
- 48
resolve(config.fetch(:service)).build(
**config, configurator: self, name: service_name
)
end
- 3
private
- 3
def config_for(name)
- 51
configurations.fetch name do
- 3
raise "Missing configuration for the #{name.inspect} Active Storage service. Configurations available for #{configurations.keys.inspect}"
end
end
- 3
def resolve(class_name)
- 48
require "active_storage/service/#{class_name.to_s.underscore}_service"
- 48
ActiveStorage::Service.const_get(:"#{class_name.camelize}Service")
rescue LoadError
raise "Missing service adapter for #{class_name.inspect}"
end
end
end
# frozen_string_literal: true
- 3
require "fileutils"
- 3
require "pathname"
- 3
require "digest/md5"
- 3
require "active_support/core_ext/numeric/bytes"
- 3
module ActiveStorage
# Wraps a local disk path as an Active Storage service. See ActiveStorage::Service for the generic API
# documentation that applies to all services.
- 3
class Service::DiskService < Service
- 3
attr_reader :root
- 3
def initialize(root:, public: false, **options)
- 42
@root = root
- 42
@public = public
end
- 3
def upload(key, io, checksum: nil, **)
- 1163
instrument :upload, key: key, checksum: checksum do
- 1163
IO.copy_stream(io, make_path_for(key))
- 1163
ensure_integrity_of(key, checksum) if checksum
end
end
- 3
def download(key, &block)
- 251
if block_given?
- 183
instrument :streaming_download, key: key do
- 183
stream key, &block
end
else
- 68
instrument :download, key: key do
- 68
File.binread path_for(key)
rescue Errno::ENOENT
- 7
raise ActiveStorage::FileNotFoundError
end
end
end
- 3
def download_chunk(key, range)
- 40
instrument :download_chunk, key: key, range: range do
- 40
File.open(path_for(key), "rb") do |file|
- 33
file.seek range.begin
- 33
file.read range.size
end
rescue Errno::ENOENT
- 7
raise ActiveStorage::FileNotFoundError
end
end
- 3
def delete(key)
- 859
instrument :delete, key: key do
- 859
File.delete path_for(key)
rescue Errno::ENOENT
# Ignore files already deleted
end
end
- 3
def delete_prefixed(prefix)
- 147
instrument :delete_prefixed, prefix: prefix do
- 147
Dir.glob(path_for("#{prefix}*")).each do |path|
- 34
FileUtils.rm_rf(path)
end
end
end
- 3
def exist?(key)
- 287
instrument :exist, key: key do |payload|
- 287
answer = File.exist? path_for(key)
- 287
payload[:exist] = answer
- 287
answer
end
end
- 3
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
- 21
instrument :url, key: key do |payload|
- 21
verified_token_with_expiration = ActiveStorage.verifier.generate(
{
key: key,
content_type: content_type,
content_length: content_length,
checksum: checksum,
service_name: name
},
expires_in: expires_in,
purpose: :blob_token
)
- 21
generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
- 21
payload[:url] = generated_url
- 21
generated_url
end
end
- 3
def headers_for_direct_upload(key, content_type:, **)
- 8
{ "Content-Type" => content_type }
end
- 3
def path_for(key) #:nodoc:
- 3551
File.join root, folder_for(key), key
end
- 3
private
- 3
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
- 91
generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
end
- 3
def public_url(key, filename:, content_type: nil, disposition: :attachment, **)
- 8
generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition)
end
- 3
def generate_url(key, expires_in:, filename:, content_type:, disposition:)
- 99
content_disposition = content_disposition_with(type: disposition, filename: filename)
- 99
verified_key_with_expiration = ActiveStorage.verifier.generate(
{
key: key,
disposition: content_disposition,
content_type: content_type,
service_name: name
},
expires_in: expires_in,
purpose: :blob_key
)
- 99
current_uri = URI.parse(current_host)
- 99
url_helpers.rails_disk_service_url(verified_key_with_expiration,
protocol: current_uri.scheme,
host: current_uri.host,
port: current_uri.port,
filename: filename
)
end
- 3
def stream(key)
- 183
File.open(path_for(key), "rb") do |file|
- 176
while data = file.read(5.megabytes)
- 185
yield data
end
end
rescue Errno::ENOENT
- 7
raise ActiveStorage::FileNotFoundError
end
- 3
def folder_for(key)
- 3551
[ key[0..1], key[2..3] ].join("/")
end
- 3
def make_path_for(key)
- 2326
path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
end
- 3
def ensure_integrity_of(key, checksum)
- 708
unless Digest::MD5.file(path_for(key)).base64digest == checksum
- 7
delete key
- 7
raise ActiveStorage::IntegrityError
end
end
- 3
def url_helpers
- 120
@url_helpers ||= Rails.application.routes.url_helpers
end
- 3
def current_host
- 120
ActiveStorage::Current.host
end
end
end
# frozen_string_literal: true
gem "google-cloud-storage", "~> 1.11"
require "google/cloud/storage"
module ActiveStorage
# Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
# documentation that applies to all services.
class Service::GCSService < Service
def initialize(public: false, **config)
@config = config
@public = public
end
def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
instrument :upload, key: key, checksum: checksum do
# GCS's signed URLs don't include params such as response-content-type response-content_disposition
# in the signature, which means an attacker can modify them and bypass our effort to force these to
# binary and attachment when the file's content type requires it. The only way to force them is to
# store them as object's metadata.
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
rescue Google::Cloud::InvalidArgumentError
raise ActiveStorage::IntegrityError
end
end
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream(key, &block)
end
else
instrument :download, key: key do
file_for(key).download.string
rescue Google::Cloud::NotFoundError
raise ActiveStorage::FileNotFoundError
end
end
end
def update_metadata(key, content_type:, disposition: nil, filename: nil)
instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
file_for(key).update do |file|
file.content_type = content_type
file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
end
end
end
def download_chunk(key, range)
instrument :download_chunk, key: key, range: range do
file_for(key).download(range: range).string
rescue Google::Cloud::NotFoundError
raise ActiveStorage::FileNotFoundError
end
end
def delete(key)
instrument :delete, key: key do
file_for(key).delete
rescue Google::Cloud::NotFoundError
# Ignore files already deleted
end
end
def delete_prefixed(prefix)
instrument :delete_prefixed, prefix: prefix do
bucket.files(prefix: prefix).all do |file|
file.delete
rescue Google::Cloud::NotFoundError
# Ignore concurrently-deleted files
end
end
end
def exist?(key)
instrument :exist, key: key do |payload|
answer = file_for(key).exists?
payload[:exist] = answer
answer
end
end
def url_for_direct_upload(key, expires_in:, checksum:, **)
instrument :url, key: key do |payload|
generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
payload[:url] = generated_url
generated_url
end
end
def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, **)
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
{ "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
end
private
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
file_for(key).signed_url expires: expires_in, query: {
"response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
"response-content-type" => content_type
}
end
def public_url(key, **)
file_for(key).public_url
end
attr_reader :config
def file_for(key, skip_lookup: true)
bucket.file(key, skip_lookup: skip_lookup)
end
# Reads the file for the given key in chunks, yielding each to the block.
def stream(key)
file = file_for(key, skip_lookup: false)
chunk_size = 5.megabytes
offset = 0
raise ActiveStorage::FileNotFoundError unless file.present?
while offset < file.size
yield file.download(range: offset..(offset + chunk_size - 1)).string
offset += chunk_size
end
end
def bucket
@bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true)
end
def client
@client ||= Google::Cloud::Storage.new(**config.except(:bucket))
end
end
end
# frozen_string_literal: true
- 3
require "active_support/core_ext/module/delegation"
- 3
module ActiveStorage
# Wraps a set of mirror services and provides a single ActiveStorage::Service object that will all
# have the files uploaded to them. A +primary+ service is designated to answer calls to:
# * +download+
# * +exists?+
# * +url+
# * +url_for_direct_upload+
# * +headers_for_direct_upload+
- 3
class Service::MirrorService < Service
- 3
attr_reader :primary, :mirrors
- 3
delegate :download, :download_chunk, :exist?, :url,
:url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
# Stitch together from named services.
- 3
def self.build(primary:, mirrors:, name:, configurator:, **options) #:nodoc:
new(
primary: configurator.build(primary),
- 18
mirrors: mirrors.collect { |mirror_name| configurator.build mirror_name }
- 6
).tap do |service_instance|
- 6
service_instance.name = name
end
end
- 3
def initialize(primary:, mirrors:)
- 6
@primary, @mirrors = primary, mirrors
end
# Upload the +io+ to the +key+ specified to all services. If a +checksum+ is provided, all services will
# ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
- 3
def upload(key, io, checksum: nil, **options)
- 92
each_service.collect do |service|
- 359
io.rewind
- 359
service.upload key, io, checksum: checksum, **options
end
end
# Delete the file at the +key+ on all services.
- 3
def delete(key)
- 104
perform_across_services :delete, key
end
# Delete files at keys starting with the +prefix+ on all services.
- 3
def delete_prefixed(prefix)
- 14
perform_across_services :delete_prefixed, prefix
end
# Copy the file at the +key+ from the primary service to each of the mirrors where it doesn't already exist.
- 3
def mirror(key, checksum:)
- 6
instrument :mirror, key: key, checksum: checksum do
- 24
if (mirrors_in_need_of_mirroring = mirrors.select { |service| !service.exist?(key) }).any?
- 6
primary.open(key, checksum: checksum) do |io|
- 6
mirrors_in_need_of_mirroring.each do |service|
- 15
io.rewind
- 15
service.upload key, io, checksum: checksum
end
end
end
end
end
- 3
private
- 3
def each_service(&block)
- 210
[ primary, *mirrors ].each(&block)
end
- 3
def perform_across_services(method, *args)
# FIXME: Convert to be threaded
- 118
each_service.collect do |service|
- 472
service.public_send method, *args
end
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
class Service::Registry #:nodoc:
- 3
def initialize(configurations)
- 3
@configurations = configurations.deep_symbolize_keys
- 3
@services = {}
end
- 3
def fetch(name)
- 2719
services.fetch(name.to_sym) do |key|
- 17
if configurations.include?(key)
- 9
services[key] = configurator.build(key)
else
- 8
if block_given?
- 8
yield key
else
raise KeyError, "Missing configuration for the #{key} Active Storage service. " \
"Configurations available for the #{configurations.keys.to_sentence} services."
end
end
end
end
- 3
private
- 3
attr_reader :configurations, :services
- 3
def configurator
- 9
@configurator ||= ActiveStorage::Service::Configurator.new(configurations)
end
end
end
# frozen_string_literal: true
gem "aws-sdk-s3", "~> 1.48"
require "aws-sdk-s3"
require "active_support/core_ext/numeric/bytes"
module ActiveStorage
# Wraps the Amazon Simple Storage Service (S3) as an Active Storage service.
# See ActiveStorage::Service for the generic API documentation that applies to all services.
class Service::S3Service < Service
attr_reader :client, :bucket
attr_reader :multipart_upload_threshold, :upload_options
def initialize(bucket:, upload: {}, public: false, **options)
@client = Aws::S3::Resource.new(**options)
@bucket = @client.bucket(bucket)
@multipart_upload_threshold = upload.fetch(:multipart_threshold, 100.megabytes)
@public = public
@upload_options = upload
@upload_options[:acl] = "public-read" if public?
end
def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
instrument :upload, key: key, checksum: checksum do
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
if io.size < multipart_upload_threshold
upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
else
upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
end
end
end
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream(key, &block)
end
else
instrument :download, key: key do
object_for(key).get.body.string.force_encoding(Encoding::BINARY)
rescue Aws::S3::Errors::NoSuchKey
raise ActiveStorage::FileNotFoundError
end
end
end
def download_chunk(key, range)
instrument :download_chunk, key: key, range: range do
object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.string.force_encoding(Encoding::BINARY)
rescue Aws::S3::Errors::NoSuchKey
raise ActiveStorage::FileNotFoundError
end
end
def delete(key)
instrument :delete, key: key do
object_for(key).delete
end
end
def delete_prefixed(prefix)
instrument :delete_prefixed, prefix: prefix do
bucket.objects(prefix: prefix).batch_delete!
end
end
def exist?(key)
instrument :exist, key: key do |payload|
answer = object_for(key).exists?
payload[:exist] = answer
answer
end
end
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
instrument :url, key: key do |payload|
generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
content_type: content_type, content_length: content_length, content_md5: checksum,
whitelist_headers: ["content-length"], **upload_options
payload[:url] = generated_url
generated_url
end
end
def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
{ "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
end
private
def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
object_for(key).presigned_url :get, expires_in: expires_in.to_i,
response_content_disposition: content_disposition_with(type: disposition, filename: filename),
response_content_type: content_type
end
def public_url(key, **)
object_for(key).public_url
end
MAXIMUM_UPLOAD_PARTS_COUNT = 10000
MINIMUM_UPLOAD_PART_SIZE = 5.megabytes
def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil)
object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, **upload_options)
rescue Aws::S3::Errors::BadDigest
raise ActiveStorage::IntegrityError
end
def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
IO.copy_stream(io, out)
end
end
def object_for(key)
bucket.object(key)
end
# Reads the object for the given key in chunks, yielding each to the block.
def stream(key)
object = object_for(key)
chunk_size = 5.megabytes
offset = 0
raise ActiveStorage::FileNotFoundError unless object.exists?
while offset < object.content_length
yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.string.force_encoding(Encoding::BINARY)
offset += chunk_size
end
end
end
end
# frozen_string_literal: true
- 3
require "image_processing"
- 3
module ActiveStorage
- 3
module Transformers
- 3
class ImageProcessingTransformer < Transformer
- 3
private
- 3
def process(file, format:)
processor.
source(file).
loader(page: 0).
convert(format).
apply(operations).
- 67
call
end
- 3
def processor
- 67
ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
end
- 3
def operations
- 64
transformations.each_with_object([]) do |(name, argument), list|
- 70
if name.to_s == "combine_options"
- 3
ActiveSupport::Deprecation.warn <<~WARNING.squish
Active Storage's ImageProcessing transformer doesn't support :combine_options,
as it always generates a single ImageMagick command. Passing :combine_options will
not be supported in Rails 6.1.
WARNING
- 9
list.concat argument.keep_if { |key, value| value.present? }.to_a
- 67
elsif argument.present?
- 64
list << [ name, argument ]
end
end
end
end
end
end
# frozen_string_literal: true
- 3
require "mini_magick"
- 3
module ActiveStorage
- 3
module Transformers
- 3
class MiniMagickTransformer < Transformer
- 3
private
- 3
def process(file, format:)
- 9
image = MiniMagick::Image.new(file.path, file)
- 9
transformations.each do |name, argument_or_subtransformations|
- 9
image.mogrify do |command|
- 9
if name.to_s == "combine_options"
- 6
argument_or_subtransformations.each do |subtransformation_name, subtransformation_argument|
- 15
pass_transform_argument(command, subtransformation_name, subtransformation_argument)
end
else
- 3
pass_transform_argument(command, name, argument_or_subtransformations)
end
end
end
- 9
image.format(format) if format
- 9
image.tempfile.tap(&:open)
end
- 3
def pass_transform_argument(command, method, argument)
- 18
if argument == true
- 3
command.public_send(method)
- 15
elsif argument.present?
- 12
command.public_send(method, argument)
end
end
end
end
end
# frozen_string_literal: true
- 3
module ActiveStorage
- 3
module Transformers
# A Transformer applies a set of transformations to an image.
#
# The following concrete subclasses are included in Active Storage:
#
# * ActiveStorage::Transformers::ImageProcessingTransformer:
# backed by ImageProcessing, a common interface for MiniMagick and ruby-vips
#
# * ActiveStorage::Transformers::MiniMagickTransformer:
# backed by MiniMagick, a wrapper around the ImageMagick CLI
- 3
class Transformer
- 3
attr_reader :transformations
- 3
def initialize(transformations)
- 76
@transformations = transformations
end
# Applies the transformations to the source image in +file+, producing a target image in the
# specified +format+. Yields an open Tempfile containing the target image. Closes and unlinks
# the output tempfile after yielding to the given block. Returns the result of the block.
- 3
def transform(file, format:)
- 76
output = process(file, format: format)
- 73
begin
- 73
yield output
ensure
- 73
output.close!
end
end
- 3
private
# Returns an open Tempfile containing a transformed image in the given +format+.
# All subclasses implement this method.
- 3
def process(file, format:) #:doc:
raise NotImplementedError
end
end
end
end
# frozen_string_literal: true
- 3
require_relative "gem_version"
- 3
module ActiveStorage
# Returns the version of the currently loaded ActiveStorage as a <tt>Gem::Version</tt>
- 3
def self.version
gem_version
end
end