aboutsummaryrefslogtreecommitdiffstats
path: root/activestorage/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'activestorage/app/models')
-rw-r--r--activestorage/app/models/active_storage/attachment.rb28
-rw-r--r--activestorage/app/models/active_storage/blob.rb195
-rw-r--r--activestorage/app/models/active_storage/filename.rb49
-rw-r--r--activestorage/app/models/active_storage/variant.rb80
-rw-r--r--activestorage/app/models/active_storage/variation.rb53
5 files changed, 405 insertions, 0 deletions
diff --git a/activestorage/app/models/active_storage/attachment.rb b/activestorage/app/models/active_storage/attachment.rb
new file mode 100644
index 0000000000..e94fc69bba
--- /dev/null
+++ b/activestorage/app/models/active_storage/attachment.rb
@@ -0,0 +1,28 @@
+require "active_support/core_ext/module/delegation"
+
+# Attachments associate records with blobs. Usually that's a one record-many blobs relationship,
+# but it is possible to associate many different records with the same blob. If you're doing that,
+# you'll want to declare with `has_one/many_attached :thingy, dependent: false`, so that destroying
+# any one record won't destroy the blob as well. (Then you'll need to do your own garbage collecting, though).
+class ActiveStorage::Attachment < ActiveRecord::Base
+ self.table_name = "active_storage_attachments"
+
+ belongs_to :record, polymorphic: true
+ belongs_to :blob, class_name: "ActiveStorage::Blob"
+
+ delegate_missing_to :blob
+
+ # Purging an attachment will purge the blob (delete the file on the service, then destroy the record)
+ # and then destroy the attachment itself.
+ def purge
+ blob.purge
+ destroy
+ end
+
+ # Purging an attachment means purging the blob, which means talking to the service, which means
+ # talking over the internet. Whenever you're doing that, it's a good idea to put that work in a job,
+ # so it doesn't hold up other operations. That's what #purge_later provides.
+ def purge_later
+ ActiveStorage::PurgeJob.perform_later(self)
+ end
+end
diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb
new file mode 100644
index 0000000000..debc62bd41
--- /dev/null
+++ b/activestorage/app/models/active_storage/blob.rb
@@ -0,0 +1,195 @@
+# A blob is a record that contains the metadata about a file and a key for where that file resides on the service.
+# Blobs can be created in two ways:
+#
+# 1) Subsequent to the file being uploaded server-side to the service via #create_after_upload!
+# 2) Ahead of the file being directly uploaded client-side to the service via #create_before_direct_upload!
+#
+# 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.
+class ActiveStorage::Blob < ActiveRecord::Base
+ self.table_name = "active_storage_blobs"
+
+ has_secure_token :key
+ store :metadata, coder: JSON
+
+ class_attribute :service
+
+ class << self
+ # You can used the signed id of a blob to refer to it on the client side without fear of tampering.
+ # This is particularly helpful for direct uploads where the client side needs to refer to the blob
+ # that was created ahead of the upload itself on form submission.
+ #
+ # The signed id is also used to create stable URLs for the blob through the BlobsController.
+ def find_signed(id)
+ find ActiveStorage.verifier.verify(id, purpose: :blob_id)
+ end
+
+ # Returns a new, unsaved blob instance after the `io` has been uploaded to the service.
+ def build_after_upload(io:, filename:, content_type: nil, metadata: nil)
+ new.tap do |blob|
+ blob.filename = filename
+ blob.content_type = content_type
+ blob.metadata = metadata
+
+ blob.upload io
+ end
+ end
+
+ # Returns a saved blob instance after the `io` has been uploaded to the service. Note, the blob is first built,
+ # then the `io` is uploaded, then the blob is saved. This is doing to avoid opening a transaction and talking to
+ # the service during that (which is a bad idea and leads to deadlocks).
+ def create_after_upload!(io:, filename:, content_type: nil, metadata: nil)
+ build_after_upload(io: io, filename: filename, content_type: content_type, metadata: metadata).tap(&:save!)
+ end
+
+ # Returns a saved blob _without_ uploading a file to the service. This blob will point to a key where there is
+ # no file yet. It's intended to be used together with a client-side upload, which will first create the blob
+ # in order to produce the signed URL for uploading. This signed URL points to the key generated by the blob.
+ # Once the form using the direct upload is submitted, the blob can be associated with the right record using
+ # the signed ID.
+ def create_before_direct_upload!(filename:, byte_size:, checksum:, content_type: nil, metadata: nil)
+ create! filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata
+ end
+ end
+
+
+ # Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
+ # It uses the framework-wide verifier on `ActiveStorage.verifier`, but with a dedicated purpose.
+ def signed_id
+ ActiveStorage.verifier.generate(id, purpose: :blob_id)
+ end
+
+ # Returns the key pointing to the file on the service that's associated with this blob. The key is in the
+ # standard secure-token format from Rails. So it'll look like: XTAPjJCJiuDrLk3TmwyJGpUo. This key is not intended
+ # to be revealed directly to the user. Always refer to blobs using the signed_id or a verified form of the key.
+ def key
+ # We can't wait until the record is first saved to have a key for it
+ self[:key] ||= self.class.generate_unique_secure_token
+ end
+
+ # Returns a `ActiveStorage::Filename` instance of the filename that can be queried for basename, extension, and
+ # a sanitized version of the filename that's safe to use in URLs.
+ def filename
+ ActiveStorage::Filename.new(self[:filename])
+ end
+
+ # Returns true if the content_type of this blob is in the image range, like image/png.
+ def image?
+ content_type =~ /^image/
+ end
+
+ # Returns true if the content_type of this blob is in the audio range, like audio/mpeg.
+ def audio?
+ content_type =~ /^audio/
+ end
+
+ # Returns true if the content_type of this blob is in the video range, like video/mp4.
+ def video?
+ content_type =~ /^video/
+ end
+
+ # Returns true if the content_type of this blob is in the text range, like text/plain.
+ def text?
+ content_type =~ /^text/
+ end
+
+ # Returns a `ActiveStorage::Variant` instance with the set of `transformations` passed in. This is only relevant
+ # for image files, and it allows any image to be transformed for size, colors, and the like. Example:
+ #
+ # avatar.variant(resize: "100x100").processed.service_url
+ #
+ # This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
+ # 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 url_for(Current.user.avatar.variant(resize: "100x100")) %>
+ #
+ # This will create a URL for that specific blob with that specific variant, which the `ActiveStorage::VariantsController`
+ # can then produce on-demand.
+ def variant(transformations)
+ ActiveStorage::Variant.new(self, ActiveStorage::Variation.new(transformations))
+ end
+
+
+ # Returns the URL of the blob on the service. This URL is intended to be short-lived for security and not used directly
+ # with users. Instead, the `service_url` should only be exposed as a redirect from a stable, possibly authenticated URL.
+ # Hiding the `service_url` behind a redirect also gives you the power to change services without updating all URLs. And
+ # it allows permanent URLs that redirect to the `service_url` to be cached in the view.
+ def service_url(expires_in: 5.minutes, disposition: :inline)
+ service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
+ end
+
+ # Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
+ # short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
+ def service_url_for_direct_upload(expires_in: 5.minutes)
+ service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
+ end
+
+ # Returns a Hash of headers for `service_url_for_direct_upload` requests.
+ def service_headers_for_direct_upload
+ service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
+ end
+
+ # Uploads the `io` to the service on the `key` for this blob. Blobs are intended to be immutable, so you shouldn't be
+ # using this method after a file has already been uploaded to fit with a blob. If you want to create a derivative blob,
+ # you should instead simply create a new blob based on the old one.
+ #
+ # Prior to uploading, we compute the checksum, which is sent to the service for transit integrity validation. If the
+ # checksum does not match what the service receives, an exception will be raised. We also measure the size of the `io`
+ # and store that in `byte_size` on the blob record.
+ #
+ # Normally, you do not have to call this method directly at all. Use the factory class methods of `build_after_upload`
+ # and `create_after_upload!`.
+ def upload(io)
+ self.checksum = compute_checksum_in_chunks(io)
+ self.byte_size = io.size
+
+ service.upload(key, io, checksum: checksum)
+ end
+
+ # Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned.
+ # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
+ def download(&block)
+ service.download key, &block
+ end
+
+
+ # Deletes the file on the service that's associated with this blob. This should only be done if the blob is going to be
+ # deleted as well or you will essentially have a dead reference. It's recommended to use the `#purge` and `#purge_later`
+ # methods in most circumstances.
+ def delete
+ service.delete key
+ end
+
+ # Deletes the file on the service and then destroys the blob record. This is the recommended way to dispose of unwanted
+ # blobs. Note, though, that deleting the file off the service will initiate a HTTP connection to the service, which may
+ # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use `#purge_later` instead.
+ def purge
+ delete
+ destroy
+ end
+
+ # Enqueues a `ActiveStorage::PurgeJob` job that'll call `#purge`. This is the recommended way to purge blobs when the call
+ # needs to be made from a transaction, a callback, or any other real-time scenario.
+ def purge_later
+ ActiveStorage::PurgeJob.perform_later(self)
+ end
+
+ private
+ def compute_checksum_in_chunks(io)
+ Digest::MD5.new.tap do |checksum|
+ while chunk = io.read(5.megabytes)
+ checksum << chunk
+ end
+
+ io.rewind
+ end.base64digest
+ end
+end
diff --git a/activestorage/app/models/active_storage/filename.rb b/activestorage/app/models/active_storage/filename.rb
new file mode 100644
index 0000000000..35f4a8ac59
--- /dev/null
+++ b/activestorage/app/models/active_storage/filename.rb
@@ -0,0 +1,49 @@
+# Encapsulates a string representing a filename to provide convenience access to parts of it and a sanitized version.
+# This is what's returned by `ActiveStorage::Blob#filename`. A Filename instance is comparable so it can be used for sorting.
+class ActiveStorage::Filename
+ include Comparable
+
+ def initialize(filename)
+ @filename = filename
+ end
+
+ # Filename.new("racecar.jpg").extname # => ".jpg"
+ def extname
+ File.extname(@filename)
+ end
+
+ # Filename.new("racecar.jpg").extension # => "jpg"
+ def extension
+ extname.from(1)
+ end
+
+ # Filename.new("racecar.jpg").base # => "racecar"
+ def base
+ File.basename(@filename, extname)
+ end
+
+ # Filename.new("foo:bar.jpg").sanitized # => "foo-bar.jpg"
+ # Filename.new("foo/bar.jpg").sanitized # => "foo-bar.jpg"
+ #
+ # ...and any other character unsafe for URLs or storage is converted or stripped.
+ def sanitized
+ @filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
+ end
+
+ # Returns the sanitized version of the filename.
+ def to_s
+ sanitized.to_s
+ end
+
+ def as_json(*)
+ to_s
+ end
+
+ def to_json
+ to_s
+ end
+
+ def <=>(other)
+ to_s.downcase <=> other.to_s.downcase
+ end
+end
diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb
new file mode 100644
index 0000000000..e3f22bcb25
--- /dev/null
+++ b/activestorage/app/models/active_storage/variant.rb
@@ -0,0 +1,80 @@
+# Image blobs can have variants that are the result of a set of transformations applied to the original.
+# These variants are used to create thumbnails, fixed-size avatars, or any other derivative image from the
+# original.
+#
+# Variants rely on `MiniMagick` for the actual transformations of the file, so you must add `gem "mini_magick"`
+# to your Gemfile if you wish to use variants.
+#
+# Note that to create a variant it's necessary to download the entire blob file from the service and load it
+# into memory. The larger the image, the more memory is used. Because of this process, you also want to be
+# considerate about when the variant is actually processed. You shouldn't be processing variants inline in a
+# template, for example. Delay the processing to an on-demand controller, like the one provided in
+# `ActiveStorage::VariantsController`.
+#
+# To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided
+# by Active Storage like so:
+#
+# <%= image_tag url_for(Current.user.avatar.variant(resize: "100x100")) %>
+#
+# This will create a URL for that specific blob with that specific variant, which the `ActiveStorage::VariantsController`
+# can then produce on-demand.
+#
+# When you do want to actually produce the variant needed, call `#processed`. This will check that the variant
+# has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform
+# the transformations, upload the variant to the service, and return itself again. Example:
+#
+# avatar.variant(resize: "100x100").processed.service_url
+#
+# This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
+# Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
+#
+# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php. You can
+# combine as many as you like freely:
+#
+# avatar.variant(resize: "100x100", monochrome: true, flip: "-90")
+class ActiveStorage::Variant
+ attr_reader :blob, :variation
+ delegate :service, to: :blob
+
+ def initialize(blob, variation)
+ @blob, @variation = blob, variation
+ end
+
+ # Returns the variant instance itself after it's been processed or an existing processing has been found on the service.
+ def processed
+ process unless processed?
+ self
+ end
+
+ # Returns a combination key of the blob and the variation that together identifies a specific variant.
+ def key
+ "variants/#{blob.key}/#{variation.key}"
+ end
+
+ # Returns the URL of the variant on the service. This URL is intended to be short-lived for security and not used directly
+ # with users. Instead, the `service_url` should only be exposed as a redirect from a stable, possibly authenticated URL.
+ # Hiding the `service_url` behind a redirect also gives you the power to change services without updating all URLs. And
+ # it allows permanent URLs that redirec to the `service_url` to be cached in the view.
+ #
+ # Use `url_for(variant)` (or the implied form, like `link_to variant` or `redirect_to variant`) to get the stable URL
+ # for a variant that points to the `ActiveStorage::VariantsController`, which in turn will use this `#service_call` method
+ # for its redirection.
+ def service_url(expires_in: 5.minutes, disposition: :inline)
+ service.url key, expires_in: expires_in, disposition: disposition, filename: blob.filename, content_type: blob.content_type
+ end
+
+
+ private
+ def processed?
+ service.exist?(key)
+ end
+
+ def process
+ service.upload key, transform(service.download(blob.key))
+ end
+
+ def transform(io)
+ require "mini_magick"
+ File.open MiniMagick::Image.read(io).tap { |image| variation.transform(image) }.path
+ end
+end
diff --git a/activestorage/app/models/active_storage/variation.rb b/activestorage/app/models/active_storage/variation.rb
new file mode 100644
index 0000000000..e784506b4c
--- /dev/null
+++ b/activestorage/app/models/active_storage/variation.rb
@@ -0,0 +1,53 @@
+require "active_support/core_ext/object/inclusion"
+
+# 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: "100x100", monochrome: true, trim: true, rotate: "-90")
+#
+# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php.
+class ActiveStorage::Variation
+ attr_reader :transformations
+
+ class << self
+ # Returns a variation instance with the transformations that were encoded by `#encode`.
+ def decode(key)
+ new ActiveStorage.verifier.verify(key, purpose: :variation)
+ end
+
+ # Returns a signed key for the `transformations`, which can be used to refer to a specific
+ # variation in a URL or combined key (like `ActiveStorage::Variant#key`).
+ def encode(transformations)
+ ActiveStorage.verifier.generate(transformations, purpose: :variation)
+ end
+ end
+
+ def initialize(transformations)
+ @transformations = transformations
+ end
+
+ # Accepts an open MiniMagick image instance, like what's return by `MiniMagick::Image.read(io)`,
+ # and performs the `transformations` against it. The transformed image instance is then returned.
+ def transform(image)
+ transformations.each do |(method, argument)|
+ if eligible_argument?(argument)
+ image.public_send(method, argument)
+ else
+ image.public_send(method)
+ end
+ end
+ end
+
+ # Returns a signed key for all the `transformations` that this variation was instantiated with.
+ def key
+ self.class.encode(transformations)
+ end
+
+ private
+ def eligible_argument?(argument)
+ argument.present? && argument != true
+ end
+end