diff options
Diffstat (limited to 'activestorage')
40 files changed, 632 insertions, 90 deletions
diff --git a/activestorage/README.md b/activestorage/README.md index 8814887950..78e4463c5a 100644 --- a/activestorage/README.md +++ b/activestorage/README.md @@ -12,6 +12,10 @@ A key difference to how Active Storage works compared to other attachment soluti `Blob` models store attachment metadata (filename, content-type, etc.), and their identifier key in the storage service. Blob models do not store the actual binary data. They are intended to be immutable in spirit. One file, one blob. You can associate the same blob with multiple application models as well. And if you want to do transformations of a given `Blob`, the idea is that you'll simply create a new one, rather than attempt to mutate the existing one (though of course you can delete the previous version later if you don't need it). +## Installation + +Run `rails active_storage:install` to copy over active_storage migrations. + ## Examples One attachment: diff --git a/activestorage/app/controllers/active_storage/disk_controller.rb b/activestorage/app/controllers/active_storage/disk_controller.rb index a4fd427cb2..a7e10c0696 100644 --- a/activestorage/app/controllers/active_storage/disk_controller.rb +++ b/activestorage/app/controllers/active_storage/disk_controller.rb @@ -5,6 +5,8 @@ # Always go through the BlobsController, or your own authenticated controller, rather than directly # to the service url. class ActiveStorage::DiskController < ActionController::Base + skip_forgery_protection if default_protect_from_forgery + def show if key = decode_verified_key send_data disk_service.download(key), diff --git a/activestorage/app/jobs/active_storage/analyze_job.rb b/activestorage/app/jobs/active_storage/analyze_job.rb new file mode 100644 index 0000000000..2a952f9f74 --- /dev/null +++ b/activestorage/app/jobs/active_storage/analyze_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Provides asynchronous analysis of ActiveStorage::Blob records via ActiveStorage::Blob#analyze_later. +class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob + def perform(blob) + blob.analyze + end +end diff --git a/activestorage/app/jobs/active_storage/base_job.rb b/activestorage/app/jobs/active_storage/base_job.rb new file mode 100644 index 0000000000..6caab42a2d --- /dev/null +++ b/activestorage/app/jobs/active_storage/base_job.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ActiveStorage::BaseJob < ActiveJob::Base + queue_as { ActiveStorage.queue } +end diff --git a/activestorage/app/jobs/active_storage/purge_job.rb b/activestorage/app/jobs/active_storage/purge_job.rb index 990ab27c9f..98874d2250 100644 --- a/activestorage/app/jobs/active_storage/purge_job.rb +++ b/activestorage/app/jobs/active_storage/purge_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -# Provides delayed purging of ActiveStorage::Blob records via ActiveStorage::Blob#purge_later. -class ActiveStorage::PurgeJob < ActiveJob::Base +# Provides asynchronous purging of ActiveStorage::Blob records via ActiveStorage::Blob#purge_later. +class ActiveStorage::PurgeJob < ActiveStorage::BaseJob # FIXME: Limit this to a custom ActiveStorage error retry_on StandardError diff --git a/activestorage/app/models/active_storage/attachment.rb b/activestorage/app/models/active_storage/attachment.rb index 29226e8ee9..9f61a5dbf3 100644 --- a/activestorage/app/models/active_storage/attachment.rb +++ b/activestorage/app/models/active_storage/attachment.rb @@ -14,6 +14,8 @@ class ActiveStorage::Attachment < ActiveRecord::Base delegate_missing_to :blob + after_create_commit :analyze_blob_later + # Synchronously purges the blob (deletes it from the configured service) and destroys the attachment. def purge blob.purge @@ -25,4 +27,9 @@ class ActiveStorage::Attachment < ActiveRecord::Base blob.purge_later destroy end + + private + def analyze_blob_later + blob.analyze_later unless blob.analyzed? + end end diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index 84b8f3827b..2aa05d665e 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_storage/analyzer/null_analyzer" + # A blob is a record that contains the metadata about a file and a key for where that file resides on the service. # Blobs can be created in two ways: # @@ -20,7 +22,7 @@ class ActiveStorage::Blob < ActiveRecord::Base self.table_name = "active_storage_blobs" has_secure_token :key - store :metadata, coder: JSON + store :metadata, accessors: [ :analyzed ], coder: JSON class_attribute :service @@ -224,6 +226,46 @@ class ActiveStorage::Blob < ActiveRecord::Base end + # Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes + # with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and + # ActiveStorage::Analyzer::VideoAnalyzer for information about the specific attributes they extract and the third-party + # libraries they require. + # + # To choose the analyzer for a blob, Active Storage calls +accept?+ on each registered analyzer in order. It uses the + # first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no + # metadata is extracted from it. + # + # In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+ + # in an initializer: + # + # # Add a custom analyzer for Microsoft Office documents: + # Rails.application.config.active_storage.analyzers.append DOCXAnalyzer + # + # # Remove the built-in video analyzer: + # Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer + # + # Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead. + # + # You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously + # analyzed via #analyze_later when they're attached for the first time. + def analyze + update! metadata: metadata.merge(extract_metadata_via_analyzer) + end + + # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze. + # + # This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob + # again (e.g. if you add a new analyzer or modify an existing one). + def analyze_later + ActiveStorage::AnalyzeJob.perform_later(self) + end + + # Returns true if the blob has been analyzed. + def analyzed? + analyzed + end + + # 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. @@ -255,4 +297,17 @@ class ActiveStorage::Blob < ActiveRecord::Base io.rewind end.base64digest end + + + def extract_metadata_via_analyzer + analyzer.metadata.merge(analyzed: true) + end + + def analyzer + analyzer_class.new(self) + end + + def analyzer_class + ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer + end end diff --git a/activestorage/config/routes.rb b/activestorage/config/routes.rb index c659e079fd..1eae21445a 100644 --- a/activestorage/config/routes.rb +++ b/activestorage/config/routes.rb @@ -3,38 +3,38 @@ Rails.application.routes.draw do get "/rails/active_storage/blobs/:signed_id/*filename" => "active_storage/blobs#show", as: :rails_service_blob, internal: true - direct :rails_blob do |blob| - route_for(:rails_service_blob, blob.signed_id, blob.filename) + direct :rails_blob do |blob, options| + route_for(:rails_service_blob, blob.signed_id, blob.filename, options) end - resolve("ActiveStorage::Blob") { |blob| route_for(:rails_blob, blob) } - resolve("ActiveStorage::Attachment") { |attachment| route_for(:rails_blob, attachment.blob) } + resolve("ActiveStorage::Blob") { |blob, options| route_for(:rails_blob, blob) } + resolve("ActiveStorage::Attachment") { |attachment, options| route_for(:rails_blob, attachment.blob, options) } get "/rails/active_storage/variants/:signed_blob_id/:variation_key/*filename" => "active_storage/variants#show", as: :rails_blob_variation, internal: true - direct :rails_variant do |variant| + direct :rails_variant do |variant, options| signed_blob_id = variant.blob.signed_id variation_key = variant.variation.key filename = variant.blob.filename - route_for(:rails_blob_variation, signed_blob_id, variation_key, filename) + route_for(:rails_blob_variation, signed_blob_id, variation_key, filename, options) end - resolve("ActiveStorage::Variant") { |variant| route_for(:rails_variant, variant) } + resolve("ActiveStorage::Variant") { |variant, options| route_for(:rails_variant, variant, options) } get "/rails/active_storage/previews/:signed_blob_id/:variation_key/*filename" => "active_storage/previews#show", as: :rails_blob_preview, internal: true - direct :rails_preview do |preview| + direct :rails_preview do |preview, options| signed_blob_id = preview.blob.signed_id variation_key = preview.variation.key filename = preview.blob.filename - route_for(:rails_blob_preview, signed_blob_id, variation_key, filename) + route_for(:rails_blob_preview, signed_blob_id, variation_key, filename, options) end - resolve("ActiveStorage::Preview") { |preview| route_for(:rails_preview, preview) } + resolve("ActiveStorage::Preview") { |preview, options| route_for(:rails_preview, preview, options) } get "/rails/active_storage/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service, internal: true diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index 44d9c25504..d1ff6b7032 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -26,7 +26,7 @@ require "active_record" require "active_support" require "active_support/rails" -require_relative "active_storage/version" +require "active_storage/version" module ActiveStorage extend ActiveSupport::Autoload @@ -34,7 +34,11 @@ module ActiveStorage autoload :Attached autoload :Service autoload :Previewer + autoload :Analyzer + mattr_accessor :logger mattr_accessor :verifier + mattr_accessor :queue mattr_accessor :previewers, default: [] + mattr_accessor :analyzers, default: [] end diff --git a/activestorage/lib/active_storage/analyzer.rb b/activestorage/lib/active_storage/analyzer.rb new file mode 100644 index 0000000000..837785a12b --- /dev/null +++ b/activestorage/lib/active_storage/analyzer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "active_storage/downloading" + +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. + class Analyzer + include Downloading + + 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. + def self.accept?(blob) + false + end + + def initialize(blob) + @blob = blob + end + + # Override this method in a concrete subclass. Have it return a Hash of metadata. + def metadata + raise NotImplementedError + end + + private + def logger + ActiveStorage.logger + end + end +end diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer.rb b/activestorage/lib/active_storage/analyzer/image_analyzer.rb new file mode 100644 index 0000000000..25e0251e6e --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/image_analyzer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ActiveStorage + # Extracts width and height in pixels from an image blob. + # + # 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. These libraries are not provided by Rails; you must + # install them yourself to use this analyzer. + class Analyzer::ImageAnalyzer < Analyzer + def self.accept?(blob) + blob.image? + end + + def metadata + read_image do |image| + { width: image.width, height: image.height } + end + rescue LoadError + logger.info "Skipping image analysis because the mini_magick gem isn't installed" + {} + end + + private + def read_image + download_blob_to_tempfile do |file| + require "mini_magick" + yield MiniMagick::Image.new(file.path) + end + end + end +end diff --git a/activestorage/lib/active_storage/analyzer/null_analyzer.rb b/activestorage/lib/active_storage/analyzer/null_analyzer.rb new file mode 100644 index 0000000000..8ff7ce48e5 --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/null_analyzer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActiveStorage + class Analyzer::NullAnalyzer < Analyzer # :nodoc: + def self.accept?(blob) + true + end + + def metadata + {} + end + end +end diff --git a/activestorage/lib/active_storage/analyzer/video_analyzer.rb b/activestorage/lib/active_storage/analyzer/video_analyzer.rb new file mode 100644 index 0000000000..408b5e58e9 --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/video_analyzer.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/compact" + +module ActiveStorage + # Extracts the following from a video blob: + # + # * Width (pixels) + # * Height (pixels) + # * Duration (seconds) + # * Angle (degrees) + # * Aspect ratio + # + # Example: + # + # ActiveStorage::VideoAnalyzer.new(blob).metadata + # # => { width: 640, height: 480, duration: 5.0, angle: 0, aspect_ratio: [4, 3] } + # + # This analyzer requires the {ffmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails. You must + # install ffmpeg yourself to use this analyzer. + class Analyzer::VideoAnalyzer < Analyzer + def self.accept?(blob) + blob.video? + end + + def metadata + { width: width, height: height, duration: duration, angle: angle, aspect_ratio: aspect_ratio }.compact + end + + private + def width + Integer(video_stream["width"]) if video_stream["width"] + end + + def height + Integer(video_stream["height"]) if video_stream["height"] + end + + def duration + Float(video_stream["duration"]) if video_stream["duration"] + end + + def angle + Integer(tags["rotate"]) if tags["rotate"] + end + + def aspect_ratio + if descriptor = video_stream["display_aspect_ratio"] + descriptor.split(":", 2).collect(&:to_i) + end + end + + + def tags + @tags ||= video_stream["tags"] || {} + end + + def video_stream + @video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {} + end + + def streams + probe["streams"] || [] + end + + def probe + download_blob_to_tempfile { |file| probe_from(file) } + end + + def probe_from(file) + IO.popen([ "ffprobe", "-print_format", "json", "-show_streams", "-v", "error", file.path ]) do |output| + JSON.parse(output.read) + end + rescue Errno::ENOENT + logger.info "Skipping video analysis because ffmpeg isn't installed" + {} + end + end +end diff --git a/activestorage/lib/active_storage/attached/macros.rb b/activestorage/lib/active_storage/attached/macros.rb index f0256718ac..2b38a9b887 100644 --- a/activestorage/lib/active_storage/attached/macros.rb +++ b/activestorage/lib/active_storage/attached/macros.rb @@ -32,6 +32,10 @@ module ActiveStorage def #{name} @active_storage_attached_#{name} ||= ActiveStorage::Attached::One.new("#{name}", self, dependent: #{dependent == :purge_later ? ":purge_later" : "false"}) end + + def #{name}=(attachable) + #{name}.attach(attachable) + end CODE has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record @@ -73,6 +77,10 @@ module ActiveStorage def #{name} @active_storage_attached_#{name} ||= ActiveStorage::Attached::Many.new("#{name}", self, dependent: #{dependent == :purge_later ? ":purge_later" : "false"}) end + + def #{name}=(attachables) + #{name}.attach(attachables) + end CODE has_many :"#{name}_attachments", -> { where(name: name) }, as: :record, class_name: "ActiveStorage::Attachment" diff --git a/activestorage/lib/active_storage/attached/many.rb b/activestorage/lib/active_storage/attached/many.rb index 1e0657c33c..6eace65b79 100644 --- a/activestorage/lib/active_storage/attached/many.rb +++ b/activestorage/lib/active_storage/attached/many.rb @@ -13,7 +13,6 @@ module ActiveStorage end # Associates one or several attachments with the current record, saving them to the database. - # Examples: # # 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 @@ -21,7 +20,11 @@ module ActiveStorage # document.images.attach([ first_blob, second_blob ]) def attach(*attachables) attachables.flatten.collect do |attachable| - attachments.create!(name: name, blob: create_blob_from(attachable)) + if record.new_record? + attachments.build(record: record, blob: create_blob_from(attachable)) + else + attachments.create!(record: record, blob: create_blob_from(attachable)) + end end end @@ -36,6 +39,11 @@ module ActiveStorage attachments.any? end + # Deletes associated attachments without purging them, leaving their respective blobs in place. + def detach + attachments.destroy_all if attached? + end + # Directly purges each associated attachment (i.e. destroys the blobs and # attachments and deletes the files on the service). def purge diff --git a/activestorage/lib/active_storage/attached/one.rb b/activestorage/lib/active_storage/attached/one.rb index c66be08f58..0244232b2c 100644 --- a/activestorage/lib/active_storage/attached/one.rb +++ b/activestorage/lib/active_storage/attached/one.rb @@ -14,7 +14,6 @@ module ActiveStorage end # Associates a given attachment with the current record, saving it to the database. - # Examples: # # person.avatar.attach(params[:avatar]) # ActionDispatch::Http::UploadedFile object # person.avatar.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload @@ -24,7 +23,7 @@ module ActiveStorage if attached? && dependent == :purge_later replace attachable else - write_attachment create_attachment_from(attachable) + write_attachment build_attachment_from(attachable) end end @@ -39,6 +38,14 @@ module ActiveStorage attachment.present? end + # Deletes the attachment without purging it, leaving its blob in place. + def detach + if attached? + attachment.destroy + write_attachment nil + end + end + # Directly purges the attachment (i.e. destroys the blob and # attachment and deletes the file on the service). def purge @@ -59,14 +66,14 @@ module ActiveStorage def replace(attachable) blob.tap do transaction do - destroy - write_attachment create_attachment_from(attachable) + detach + write_attachment build_attachment_from(attachable) end end.purge_later end - def create_attachment_from(attachable) - ActiveStorage::Attachment.create!(record: record, name: name, blob: create_blob_from(attachable)) + def build_attachment_from(attachable) + ActiveStorage::Attachment.new(record: record, name: name, blob: create_blob_from(attachable)) end def write_attachment(attachment) diff --git a/activestorage/lib/active_storage/downloading.rb b/activestorage/lib/active_storage/downloading.rb new file mode 100644 index 0000000000..3dac6b116a --- /dev/null +++ b/activestorage/lib/active_storage/downloading.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveStorage + module Downloading + private + # Opens a new tempfile in #tempdir and copies blob data into it. Yields the tempfile. + def download_blob_to_tempfile # :doc: + Tempfile.open("ActiveStorage", tempdir) do |file| + download_blob_to file + yield file + 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.rewind + end + + # Returns the directory in which tempfiles should be opened. Defaults to +Dir.tmpdir+. + def tempdir # :doc: + Dir.tmpdir + end + end +end diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 335eae8dd8..6cf6635c4f 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -6,20 +6,25 @@ require "active_storage" require "active_storage/previewer/pdf_previewer" require "active_storage/previewer/video_previewer" +require "active_storage/analyzer/image_analyzer" +require "active_storage/analyzer/video_analyzer" + module ActiveStorage class Engine < Rails::Engine # :nodoc: isolate_namespace ActiveStorage config.active_storage = ActiveSupport::OrderedOptions.new config.active_storage.previewers = [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ] + config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ] config.eager_load_namespaces << ActiveStorage - initializer "active_storage.logger" do - require "active_storage/service" - + initializer "active_storage.configs" do config.after_initialize do |app| - ActiveStorage::Service.logger = app.config.active_storage.logger || Rails.logger + ActiveStorage.logger = app.config.active_storage.logger || Rails.logger + ActiveStorage.queue = app.config.active_storage.queue + ActiveStorage.previewers = app.config.active_storage.previewers || [] + ActiveStorage.analyzers = app.config.active_storage.analyzers || [] end end @@ -63,11 +68,5 @@ module ActiveStorage end end end - - initializer "active_storage.previewers" do - config.after_initialize do |app| - ActiveStorage.previewers = app.config.active_storage.previewers || [] - end - end end end diff --git a/activestorage/lib/active_storage/log_subscriber.rb b/activestorage/lib/active_storage/log_subscriber.rb index 0d00a75c0e..5cbf4bd1a5 100644 --- a/activestorage/lib/active_storage/log_subscriber.rb +++ b/activestorage/lib/active_storage/log_subscriber.rb @@ -27,7 +27,7 @@ module ActiveStorage end def logger - ActiveStorage::Service.logger + ActiveStorage.logger end private diff --git a/activestorage/lib/active_storage/previewer.rb b/activestorage/lib/active_storage/previewer.rb index 930b376067..ed75bae3b5 100644 --- a/activestorage/lib/active_storage/previewer.rb +++ b/activestorage/lib/active_storage/previewer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true +require "active_storage/downloading" + module ActiveStorage # This is an abstract base class for previewers, which generate images from blobs. See # ActiveStorage::Previewer::PDFPreviewer and ActiveStorage::Previewer::VideoPreviewer for examples of # concrete subclasses. class Previewer + include Downloading + attr_reader :blob # Implement this method in a concrete subclass. Have it return true when given a blob from which @@ -24,37 +28,22 @@ module ActiveStorage end private - # Downloads the blob to a new tempfile. Yields the tempfile. - # - # Use this method to get a tempfile that you can provide to a drawing command. - def open # :doc: - Tempfile.open("input") do |file| - download_blob_to file - yield file - end - end - - def download_blob_to(file) - file.binmode - blob.download { |chunk| file.write(chunk) } - file.rewind - end - - # Executes a system command, capturing its binary output in a tempfile. Yields the tempfile. # - # Use this method to shell out to system libraries (e.g. mupdf or ffmpeg) for preview image + # 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 - # open do |input| + # 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 ActiveStorage::Downloading#tempdir. def draw(*argv) # :doc: - Tempfile.open("output") do |file| + Tempfile.open("ActiveStorage", tempdir) do |file| capture(*argv, to: file) yield file end diff --git a/activestorage/lib/active_storage/previewer/pdf_previewer.rb b/activestorage/lib/active_storage/previewer/pdf_previewer.rb index 31a2a8f120..a2f05c74a6 100644 --- a/activestorage/lib/active_storage/previewer/pdf_previewer.rb +++ b/activestorage/lib/active_storage/previewer/pdf_previewer.rb @@ -7,7 +7,7 @@ module ActiveStorage end def preview - open do |input| + download_blob_to_tempfile do |input| draw "mutool", "draw", "-F", "png", "-o", "-", input.path, "1" do |output| yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png" end diff --git a/activestorage/lib/active_storage/previewer/video_previewer.rb b/activestorage/lib/active_storage/previewer/video_previewer.rb index 840d87f100..49f128d142 100644 --- a/activestorage/lib/active_storage/previewer/video_previewer.rb +++ b/activestorage/lib/active_storage/previewer/video_previewer.rb @@ -7,11 +7,17 @@ module ActiveStorage end def preview - open do |input| - draw "ffmpeg", "-i", input.path, "-y", "-vcodec", "png", "-vf", "thumbnail", "-vframes", "1", "-f", "image2", "-" do |output| + download_blob_to_tempfile do |input| + draw_relevant_frame_from input do |output| yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png" end end end + + private + def draw_relevant_frame_from(file, &block) + draw "ffmpeg", "-i", file.path, "-y", "-vcodec", "png", + "-vf", "thumbnail", "-vframes", "1", "-f", "image2", "-", &block + end end end diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index 1f012da1e7..aa150e4d8a 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -41,8 +41,6 @@ module ActiveStorage extend ActiveSupport::Autoload autoload :Configurator - class_attribute :logger - class_attribute :url_expires_in, default: 5.minutes class << self diff --git a/activestorage/lib/active_storage/service/azure_storage_service.rb b/activestorage/lib/active_storage/service/azure_storage_service.rb index 27dd192ce6..f3877ad9c9 100644 --- a/activestorage/lib/active_storage/service/azure_storage_service.rb +++ b/activestorage/lib/active_storage/service/azure_storage_service.rb @@ -28,7 +28,7 @@ module ActiveStorage end end - def download(key) + def download(key, &block) if block_given? instrument :streaming_download, key do stream(key, &block) @@ -108,15 +108,15 @@ module ActiveStorage end # Reads the object for the given key in chunks, yielding each to the block. - def stream(key, options = {}, &block) + def stream(key) blob = blob_for(key) chunk_size = 5.megabytes offset = 0 while offset < blob.properties[:content_length] - _, io = blobs.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1) - yield io + _, chunk = blobs.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1) + yield chunk.force_encoding(Encoding::BINARY) offset += chunk_size end end diff --git a/activestorage/lib/active_storage/service/gcs_service.rb b/activestorage/lib/active_storage/service/gcs_service.rb index b4ffeeeb8a..be6ddf32a0 100644 --- a/activestorage/lib/active_storage/service/gcs_service.rb +++ b/activestorage/lib/active_storage/service/gcs_service.rb @@ -7,11 +7,8 @@ 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 - attr_reader :client, :bucket - - def initialize(project:, keyfile:, bucket:, **options) - @client = Google::Cloud::Storage.new(project: project, keyfile: keyfile, **options) - @bucket = @client.bucket(bucket) + def initialize(**config) + @config = config end def upload(key, io, checksum: nil) @@ -85,8 +82,18 @@ module ActiveStorage end private + attr_reader :config + def file_for(key) bucket.file(key, skip_lookup: true) end + + def bucket + @bucket ||= client.bucket(config.fetch(:bucket)) + end + + def client + @client ||= Google::Cloud::Storage.new(config.except(:bucket)) + end end end diff --git a/activestorage/lib/active_storage/service/s3_service.rb b/activestorage/lib/active_storage/service/s3_service.rb index 3e93cdd072..6957119780 100644 --- a/activestorage/lib/active_storage/service/s3_service.rb +++ b/activestorage/lib/active_storage/service/s3_service.rb @@ -26,7 +26,7 @@ module ActiveStorage end end - def download(key) + def download(key, &block) if block_given? instrument :streaming_download, key do stream(key, &block) @@ -85,14 +85,14 @@ module ActiveStorage end # Reads the object for the given key in chunks, yielding each to the block. - def stream(key, options = {}, &block) + def stream(key) object = object_for(key) chunk_size = 5.megabytes offset = 0 while offset < object.content_length - yield object.read(options.merge(range: "bytes=#{offset}-#{offset + chunk_size - 1}")) + yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.read.force_encoding(Encoding::BINARY) offset += chunk_size end end diff --git a/activestorage/test/analyzer/image_analyzer_test.rb b/activestorage/test/analyzer/image_analyzer_test.rb new file mode 100644 index 0000000000..9087072215 --- /dev/null +++ b/activestorage/test/analyzer/image_analyzer_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" +require "database/setup" + +require "active_storage/analyzer/image_analyzer" + +class ActiveStorage::Analyzer::ImageAnalyzerTest < ActiveSupport::TestCase + test "analyzing an image" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + metadata = blob.tap(&:analyze).metadata + + assert_equal 4104, metadata[:width] + assert_equal 2736, metadata[:height] + end +end diff --git a/activestorage/test/analyzer/video_analyzer_test.rb b/activestorage/test/analyzer/video_analyzer_test.rb new file mode 100644 index 0000000000..4a3c4a8bfc --- /dev/null +++ b/activestorage/test/analyzer/video_analyzer_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" +require "database/setup" + +require "active_storage/analyzer/video_analyzer" + +class ActiveStorage::Analyzer::VideoAnalyzerTest < ActiveSupport::TestCase + test "analyzing a video" do + blob = create_file_blob(filename: "video.mp4", content_type: "video/mp4") + metadata = blob.tap(&:analyze).metadata + + assert_equal 640, metadata[:width] + assert_equal 480, metadata[:height] + assert_equal [4, 3], metadata[:aspect_ratio] + assert_equal 5.166648, metadata[:duration] + assert_not_includes metadata, :angle + end + + test "analyzing a rotated video" do + blob = create_file_blob(filename: "rotated_video.mp4", content_type: "video/mp4") + metadata = blob.tap(&:analyze).metadata + + assert_equal 640, metadata[:width] + assert_equal 480, metadata[:height] + assert_equal [4, 3], metadata[:aspect_ratio] + assert_equal 5.227975, metadata[:duration] + assert_equal 90, metadata[:angle] + end + + test "analyzing a video without a video stream" do + blob = create_file_blob(filename: "video_without_video_stream.mp4", content_type: "video/mp4") + assert_equal({ "analyzed" => true }, blob.tap(&:analyze).metadata) + end +end diff --git a/activestorage/test/database/setup.rb b/activestorage/test/database/setup.rb index 87564499e6..705650a25d 100644 --- a/activestorage/test/database/setup.rb +++ b/activestorage/test/database/setup.rb @@ -3,5 +3,5 @@ require_relative "create_users_migration" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") -ActiveRecord::Migrator.migrate File.expand_path("../../../db/migrate", __FILE__) +ActiveRecord::Migrator.migrate File.expand_path("../../db/migrate", __dir__) ActiveStorageCreateUsers.migrate(:up) diff --git a/activestorage/test/dummy/bin/bundle b/activestorage/test/dummy/bin/bundle index 277e128251..5015ba6f8b 100755 --- a/activestorage/test/dummy/bin/bundle +++ b/activestorage/test/dummy/bin/bundle @@ -1,5 +1,5 @@ #!/usr/bin/env ruby # frozen_string_literal: true -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) load Gem.bin_path("bundler", "bundle") diff --git a/activestorage/test/dummy/config/application.rb b/activestorage/test/dummy/config/application.rb index 7ee6625bb5..06cbc453a2 100644 --- a/activestorage/test/dummy/config/application.rb +++ b/activestorage/test/dummy/config/application.rb @@ -19,7 +19,7 @@ Bundler.require(*Rails.groups) module Dummy class Application < Rails::Application - config.load_defaults 5.1 + config.load_defaults 5.2 config.active_storage.service = :local end diff --git a/activestorage/test/dummy/config/environments/test.rb b/activestorage/test/dummy/config/environments/test.rb index ce0889e8ae..74a802d98c 100644 --- a/activestorage/test/dummy/config/environments/test.rb +++ b/activestorage/test/dummy/config/environments/test.rb @@ -30,6 +30,9 @@ Rails.application.configure do # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + # Raises error for missing translations # config.action_view.raise_on_missing_translations = true end diff --git a/activestorage/test/fixtures/files/rotated_video.mp4 b/activestorage/test/fixtures/files/rotated_video.mp4 Binary files differnew file mode 100644 index 0000000000..4c7a4e9e57 --- /dev/null +++ b/activestorage/test/fixtures/files/rotated_video.mp4 diff --git a/activestorage/test/fixtures/files/video_without_video_stream.mp4 b/activestorage/test/fixtures/files/video_without_video_stream.mp4 Binary files differnew file mode 100644 index 0000000000..e6a55f868b --- /dev/null +++ b/activestorage/test/fixtures/files/video_without_video_stream.mp4 diff --git a/activestorage/test/models/attachments_test.rb b/activestorage/test/models/attachments_test.rb index 379ae0a416..20eec3c220 100644 --- a/activestorage/test/models/attachments_test.rb +++ b/activestorage/test/models/attachments_test.rb @@ -15,7 +15,7 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase assert_equal "funky.jpg", @user.avatar.filename.to_s end - test "attach existing sgid blob" do + test "attach existing blob from a signed ID" do @user.avatar.attach create_blob(filename: "funky.jpg").signed_id assert_equal "funky.jpg", @user.avatar.filename.to_s end @@ -27,7 +27,7 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase test "attach new blob from an UploadedFile" do file = file_fixture "racecar.jpg" - @user.avatar.attach Rack::Test::UploadedFile.new file + @user.avatar.attach Rack::Test::UploadedFile.new file.to_s assert_equal "racecar.jpg", @user.avatar.filename.to_s end @@ -56,6 +56,40 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase assert ActiveStorage::Blob.service.exist?(@user.avatar.key) end + test "attach blob to new record" do + user = User.new(name: "Jason") + + assert_no_changes -> { user.new_record? } do + assert_no_difference -> { ActiveStorage::Attachment.count } do + user.avatar.attach create_blob(filename: "funky.jpg") + end + end + + assert user.avatar.attached? + assert_equal "funky.jpg", user.avatar.filename.to_s + + assert_difference -> { ActiveStorage::Attachment.count }, +1 do + user.save! + end + + assert user.reload.avatar.attached? + assert_equal "funky.jpg", user.avatar.filename.to_s + end + + test "build new record with attached blob" do + assert_no_difference -> { ActiveStorage::Attachment.count } do + @user = User.new(name: "Jason", avatar: { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg" }) + end + + assert @user.new_record? + assert @user.avatar.attached? + assert_equal "town.jpg", @user.avatar.filename.to_s + + @user.save! + assert @user.reload.avatar.attached? + assert_equal "town.jpg", @user.avatar.filename.to_s + end + test "access underlying associations of new blob" do @user.avatar.attach create_blob(filename: "funky.jpg") assert_equal @user, @user.avatar_attachment.record @@ -63,6 +97,52 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase assert_equal "funky.jpg", @user.avatar_attachment.blob.filename.to_s end + test "analyze newly-attached blob" do + perform_enqueued_jobs do + @user.avatar.attach create_file_blob + end + + assert_equal 4104, @user.avatar.reload.metadata[:width] + assert_equal 2736, @user.avatar.metadata[:height] + end + + test "analyze attached blob only once" do + blob = create_file_blob + + perform_enqueued_jobs do + @user.avatar.attach blob + end + + assert blob.reload.analyzed? + + @user.avatar.attachment.destroy + + assert_no_enqueued_jobs do + @user.reload.avatar.attach blob + end + end + + test "preserve existing metadata when analyzing a newly-attached blob" do + blob = create_file_blob(metadata: { foo: "bar" }) + + perform_enqueued_jobs do + @user.avatar.attach blob + end + + assert_equal "bar", blob.reload.metadata[:foo] + end + + test "detach blob" do + @user.avatar.attach create_blob(filename: "funky.jpg") + avatar_blob_id = @user.avatar.blob.id + avatar_key = @user.avatar.key + + @user.avatar.detach + assert_not @user.avatar.attached? + assert ActiveStorage::Blob.exists?(avatar_blob_id) + assert ActiveStorage::Blob.service.exist?(avatar_key) + end + test "purge attached blob" do @user.avatar.attach create_blob(filename: "funky.jpg") avatar_key = @user.avatar.key @@ -114,6 +194,48 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase assert_equal "country.jpg", @user.highlights.second.filename.to_s end + test "attach blobs to new record" do + user = User.new(name: "Jason") + + assert_no_changes -> { user.new_record? } do + assert_no_difference -> { ActiveStorage::Attachment.count } do + user.highlights.attach( + { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg" }, + { io: StringIO.new("IT"), filename: "country.jpg", content_type: "image/jpg" }) + end + end + + assert user.highlights.attached? + assert_equal "town.jpg", user.highlights.first.filename.to_s + assert_equal "country.jpg", user.highlights.second.filename.to_s + + assert_difference -> { ActiveStorage::Attachment.count }, +2 do + user.save! + end + + assert user.reload.highlights.attached? + assert_equal "town.jpg", user.highlights.first.filename.to_s + assert_equal "country.jpg", user.highlights.second.filename.to_s + end + + test "build new record with attached blobs" do + assert_no_difference -> { ActiveStorage::Attachment.count } do + @user = User.new(name: "Jason", highlights: [ + { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg" }, + { io: StringIO.new("IT"), filename: "country.jpg", content_type: "image/jpg" }]) + end + + assert @user.new_record? + assert @user.highlights.attached? + assert_equal "town.jpg", @user.highlights.first.filename.to_s + assert_equal "country.jpg", @user.highlights.second.filename.to_s + + @user.save! + assert @user.reload.highlights.attached? + assert_equal "town.jpg", @user.highlights.first.filename.to_s + assert_equal "country.jpg", @user.highlights.second.filename.to_s + end + test "find attached blobs" do @user.highlights.attach( { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg" }, @@ -135,6 +257,68 @@ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase assert_equal "town.jpg", @user.highlights_attachments.first.blob.filename.to_s end + test "analyze newly-attached blobs" do + perform_enqueued_jobs do + @user.highlights.attach( + create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg"), + create_file_blob(filename: "video.mp4", content_type: "video/mp4")) + end + + assert_equal 4104, @user.highlights.first.metadata[:width] + assert_equal 2736, @user.highlights.first.metadata[:height] + + assert_equal 640, @user.highlights.second.metadata[:width] + assert_equal 480, @user.highlights.second.metadata[:height] + end + + test "analyze attached blobs only once" do + blobs = [ + create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg"), + create_file_blob(filename: "video.mp4", content_type: "video/mp4") + ] + + perform_enqueued_jobs do + @user.highlights.attach(blobs) + end + + assert blobs.each(&:reload).all?(&:analyzed?) + + @user.highlights.attachments.destroy_all + + assert_no_enqueued_jobs do + @user.highlights.attach(blobs) + end + end + + test "preserve existing metadata when analyzing newly-attached blobs" do + blobs = [ + create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg", metadata: { foo: "bar" }), + create_file_blob(filename: "video.mp4", content_type: "video/mp4", metadata: { foo: "bar" }) + ] + + perform_enqueued_jobs do + @user.highlights.attach(blobs) + end + + blobs.each do |blob| + assert_equal "bar", blob.reload.metadata[:foo] + end + end + + test "detach blobs" do + @user.highlights.attach create_blob(filename: "funky.jpg"), create_blob(filename: "wonky.jpg") + highlight_blob_ids = @user.highlights.collect { |highlight| highlight.blob.id } + highlight_keys = @user.highlights.collect(&:key) + + @user.highlights.detach + assert_not @user.highlights.attached? + + assert ActiveStorage::Blob.exists?(highlight_blob_ids.first) + assert ActiveStorage::Blob.exists?(highlight_blob_ids.second) + + assert ActiveStorage::Blob.service.exist?(highlight_keys.first) + assert ActiveStorage::Blob.service.exist?(highlight_keys.second) + end test "purge attached blobs" do @user.highlights.attach create_blob(filename: "funky.jpg"), create_blob(filename: "wonky.jpg") diff --git a/activestorage/test/previewer/pdf_previewer_test.rb b/activestorage/test/previewer/pdf_previewer_test.rb index 60f075d1b2..fe32f39be4 100644 --- a/activestorage/test/previewer/pdf_previewer_test.rb +++ b/activestorage/test/previewer/pdf_previewer_test.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "test_helper" +require "database/setup" + require "active_storage/previewer/pdf_previewer" class ActiveStorage::Previewer::PDFPreviewerTest < ActiveSupport::TestCase diff --git a/activestorage/test/previewer/video_previewer_test.rb b/activestorage/test/previewer/video_previewer_test.rb index 967d5d5ba9..dba9b0d7e2 100644 --- a/activestorage/test/previewer/video_previewer_test.rb +++ b/activestorage/test/previewer/video_previewer_test.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "test_helper" +require "database/setup" + require "active_storage/previewer/video_previewer" class ActiveStorage::Previewer::VideoPreviewerTest < ActiveSupport::TestCase diff --git a/activestorage/test/service/gcs_service_test.rb b/activestorage/test/service/gcs_service_test.rb index 5566c664a9..1860149da9 100644 --- a/activestorage/test/service/gcs_service_test.rb +++ b/activestorage/test/service/gcs_service_test.rb @@ -32,13 +32,8 @@ if SERVICE_CONFIGURATIONS[:gcs] end test "signed URL generation" do - freeze_time do - url = SERVICE.bucket.signed_url(FIXTURE_KEY, expires: 120) + - "&response-content-disposition=inline%3B+filename%3D%22test.txt%22%3B+filename%2A%3DUTF-8%27%27test.txt" + - "&response-content-type=text%2Fplain" - - assert_equal url, @service.url(FIXTURE_KEY, expires_in: 2.minutes, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") - end + assert_match(/storage\.googleapis\.com\/.*response-content-disposition=inline.*test\.txt.*response-content-type=text%2Fplain/, + @service.url(FIXTURE_KEY, expires_in: 2.minutes, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain")) end end else diff --git a/activestorage/test/service/shared_service_tests.rb b/activestorage/test/service/shared_service_tests.rb index a9e1cb6ce9..ade91ab89a 100644 --- a/activestorage/test/service/shared_service_tests.rb +++ b/activestorage/test/service/shared_service_tests.rb @@ -50,6 +50,16 @@ module ActiveStorage::Service::SharedServiceTests assert_equal FIXTURE_DATA, @service.download(FIXTURE_KEY) end + test "downloading in chunks" do + chunks = [] + + @service.download(FIXTURE_KEY) do |chunk| + chunks << chunk + end + + assert_equal [ FIXTURE_DATA ], chunks + end + test "existing" do assert @service.exist?(FIXTURE_KEY) assert_not @service.exist?(FIXTURE_KEY + "nonsense") diff --git a/activestorage/test/test_helper.rb b/activestorage/test/test_helper.rb index dd37239060..aaf1d452ea 100644 --- a/activestorage/test/test_helper.rb +++ b/activestorage/test/test_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require File.expand_path("../../test/dummy/config/environment.rb", __FILE__) +ENV["RAILS_ENV"] ||= "test" +require_relative "dummy/config/environment.rb" require "bundler/setup" require "active_support" @@ -23,7 +24,7 @@ Minitest.backtrace_filter = Minitest::BacktraceFilter.new require "yaml" SERVICE_CONFIGURATIONS = begin - erb = ERB.new(Pathname.new(File.expand_path("../service/configurations.yml", __FILE__)).read) + erb = ERB.new(Pathname.new(File.expand_path("service/configurations.yml", __dir__)).read) configuration = YAML.load(erb.result) || {} configuration.deep_symbolize_keys rescue Errno::ENOENT @@ -33,22 +34,20 @@ end require "tmpdir" ActiveStorage::Blob.service = ActiveStorage::Service::DiskService.new(root: Dir.mktmpdir("active_storage_tests")) -ActiveStorage::Service.logger = ActiveSupport::Logger.new(nil) +ActiveStorage.logger = ActiveSupport::Logger.new(nil) ActiveStorage.verifier = ActiveSupport::MessageVerifier.new("Testing") class ActiveSupport::TestCase - self.file_fixture_path = File.expand_path("../fixtures/files", __FILE__) + self.file_fixture_path = File.expand_path("fixtures/files", __dir__) private def create_blob(data: "Hello world!", filename: "hello.txt", content_type: "text/plain") ActiveStorage::Blob.create_after_upload! io: StringIO.new(data), filename: filename, content_type: content_type end - def create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") - ActiveStorage::Blob.create_after_upload! \ - io: file_fixture(filename).open, - filename: filename, content_type: content_type + def create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg", metadata: nil) + ActiveStorage::Blob.create_after_upload! io: file_fixture(filename).open, filename: filename, content_type: content_type, metadata: metadata end def create_blob_before_direct_upload(filename: "hello.txt", byte_size:, checksum:, content_type: "text/plain") |