diff options
author | Rafael Mendonça França <rafaelmfranca@gmail.com> | 2017-07-31 15:21:22 -0400 |
---|---|---|
committer | Rafael Mendonça França <rafaelmfranca@gmail.com> | 2017-07-31 15:21:22 -0400 |
commit | 9330d01ada9ce6768d14c59b99cd3860e209737a (patch) | |
tree | e4328b4e59b52dae35241f835f786641d1ff8595 /activestorage/app/models/active_storage | |
parent | 0d58e7e478e79c2d6b2a39a4444d2a17a903b2a6 (diff) | |
parent | 3f4a7218a4a4923a0e7ce1b2eb0d2888ce30da58 (diff) | |
download | rails-9330d01ada9ce6768d14c59b99cd3860e209737a.tar.gz rails-9330d01ada9ce6768d14c59b99cd3860e209737a.tar.bz2 rails-9330d01ada9ce6768d14c59b99cd3860e209737a.zip |
Add 'activestorage/' from commit '3f4a7218a4a4923a0e7ce1b2eb0d2888ce30da58'
git-subtree-dir: activestorage
git-subtree-mainline: 0d58e7e478e79c2d6b2a39a4444d2a17a903b2a6
git-subtree-split: 3f4a7218a4a4923a0e7ce1b2eb0d2888ce30da58
Diffstat (limited to 'activestorage/app/models/active_storage')
-rw-r--r-- | activestorage/app/models/active_storage/attachment.rb | 29 | ||||
-rw-r--r-- | activestorage/app/models/active_storage/blob.rb | 193 | ||||
-rw-r--r-- | activestorage/app/models/active_storage/filename.rb | 49 | ||||
-rw-r--r-- | activestorage/app/models/active_storage/variant.rb | 82 | ||||
-rw-r--r-- | activestorage/app/models/active_storage/variation.rb | 53 |
5 files changed, 406 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..2c8b7a9cf2 --- /dev/null +++ b/activestorage/app/models/active_storage/attachment.rb @@ -0,0 +1,29 @@ +require "active_storage/blob" +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..9208d36ee3 --- /dev/null +++ b/activestorage/app/models/active_storage/blob.rb @@ -0,0 +1,193 @@ +require "active_storage/service" +require "active_storage/filename" +require "active_storage/purge_job" +require "active_storage/variant" +require "active_storage/variation" + +# 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..a8e64f781e --- /dev/null +++ b/activestorage/app/models/active_storage/variant.rb @@ -0,0 +1,82 @@ +require "active_storage/blob" + +# 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..34b854fd9f --- /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 |