diff options
Diffstat (limited to 'activestorage/lib/active_storage')
36 files changed, 976 insertions, 319 deletions
diff --git a/activestorage/lib/active_storage/analyzer.rb b/activestorage/lib/active_storage/analyzer.rb index 837785a12b..caa25418a5 100644 --- a/activestorage/lib/active_storage/analyzer.rb +++ b/activestorage/lib/active_storage/analyzer.rb @@ -1,13 +1,9 @@ # 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 @@ -26,8 +22,17 @@ module ActiveStorage end private - def logger + # Downloads the blob to a tempfile on disk. Yields the tempfile. + def download_blob_to_tempfile(&block) #:doc: + blob.open tempdir: tempdir, &block + end + + def logger #:doc: ActiveStorage.logger end + + def tempdir #:doc: + Dir.tmpdir + end end end diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer.rb b/activestorage/lib/active_storage/analyzer/image_analyzer.rb index 25e0251e6e..3b39de91be 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer.rb @@ -3,14 +3,15 @@ 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. These libraries are not provided by Rails; you must - # install them yourself to use this analyzer. + # the {ImageMagick}[http://www.imagemagick.org] system library. class Analyzer::ImageAnalyzer < Analyzer def self.accept?(blob) blob.image? @@ -18,7 +19,11 @@ module ActiveStorage def metadata read_image do |image| - { width: image.width, height: image.height } + if rotated_image?(image) + { width: image.height, height: image.width } + else + { width: image.width, height: image.height } + end end rescue LoadError logger.info "Skipping image analysis because the mini_magick gem isn't installed" @@ -32,5 +37,9 @@ module ActiveStorage yield MiniMagick::Image.new(file.path) end end + + def rotated_image?(image) + %w[ RightTop LeftBottom ].include?(image["%[orientation]"]) + end end end diff --git a/activestorage/lib/active_storage/analyzer/video_analyzer.rb b/activestorage/lib/active_storage/analyzer/video_analyzer.rb index 1c144baa37..18d8ff8237 100644 --- a/activestorage/lib/active_storage/analyzer/video_analyzer.rb +++ b/activestorage/lib/active_storage/analyzer/video_analyzer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/hash/compact" - module ActiveStorage # Extracts the following from a video blob: # @@ -9,41 +7,40 @@ module ActiveStorage # * Height (pixels) # * Duration (seconds) # * Angle (degrees) - # * Aspect ratio + # * Display aspect ratio # # Example: # # ActiveStorage::VideoAnalyzer.new(blob).metadata - # # => { width: 640, height: 480, duration: 5.0, angle: 0, aspect_ratio: [4, 3] } + # # => { 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. You must - # install ffmpeg yourself to use this analyzer. + # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails. class Analyzer::VideoAnalyzer < Analyzer - class_attribute :ffprobe_path, default: "ffprobe" - def self.accept?(blob) blob.video? end def metadata - { width: width, height: height, duration: duration, angle: angle, aspect_ratio: aspect_ratio }.compact + { width: width, height: height, duration: duration, angle: angle, display_aspect_ratio: display_aspect_ratio }.compact end private def width - rotated? ? raw_height : raw_width + if rotated? + computed_height || encoded_height + else + encoded_width + end end def height - rotated? ? raw_width : raw_height - end - - def raw_width - Integer(video_stream["width"]) if video_stream["width"] - end - - def raw_height - Integer(video_stream["height"]) if video_stream["height"] + if rotated? + encoded_width + else + computed_height || encoded_height + end end def duration @@ -54,16 +51,40 @@ module ActiveStorage Integer(tags["rotate"]) if tags["rotate"] end - def aspect_ratio + def display_aspect_ratio if descriptor = video_stream["display_aspect_ratio"] - descriptor.split(":", 2).collect(&:to_i) + if terms = descriptor.split(":", 2) + numerator = Integer(terms[0]) + denominator = Integer(terms[1]) + + [numerator, denominator] unless numerator == 0 + end end end + def rotated? angle == 90 || angle == 270 end + def computed_height + if encoded_width && display_height_scale + encoded_width * display_height_scale + end + end + + def encoded_width + @encoded_width ||= Float(video_stream["width"]) if video_stream["width"] + end + + def encoded_height + @encoded_height ||= Float(video_stream["height"]) if video_stream["height"] + end + + def display_height_scale + @display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio + end + def tags @tags ||= video_stream["tags"] || {} @@ -86,8 +107,12 @@ module ActiveStorage JSON.parse(output.read) end rescue Errno::ENOENT - logger.info "Skipping video analysis because ffmpeg isn't installed" + logger.info "Skipping video analysis because FFmpeg isn't installed" {} end + + def ffprobe_path + ActiveStorage.paths[:ffprobe] || "ffprobe" + end end end diff --git a/activestorage/lib/active_storage/attached.rb b/activestorage/lib/active_storage/attached.rb index c08fd56652..b540f85fbe 100644 --- a/activestorage/lib/active_storage/attached.rb +++ b/activestorage/lib/active_storage/attached.rb @@ -1,40 +1,25 @@ # frozen_string_literal: true -require "action_dispatch" -require "action_dispatch/http/upload" require "active_support/core_ext/module/delegation" 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. class Attached - attr_reader :name, :record, :dependent + attr_reader :name, :record - def initialize(name, record, dependent:) - @name, @record, @dependent = name, record, dependent + def initialize(name, record) + @name, @record = name, record end private - def create_blob_from(attachable) - case attachable - when ActiveStorage::Blob - attachable - when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile - ActiveStorage::Blob.create_after_upload! \ - io: attachable.open, - filename: attachable.original_filename, - content_type: attachable.content_type - when Hash - ActiveStorage::Blob.create_after_upload!(attachable) - when String - ActiveStorage::Blob.find_signed(attachable) - else - nil - end + def change + record.attachment_changes[name] end end end +require "active_storage/attached/model" require "active_storage/attached/one" require "active_storage/attached/many" -require "active_storage/attached/macros" +require "active_storage/attached/changes" diff --git a/activestorage/lib/active_storage/attached/changes.rb b/activestorage/lib/active_storage/attached/changes.rb new file mode 100644 index 0000000000..1db3906a63 --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ActiveStorage + module Attached::Changes #:nodoc: + extend ActiveSupport::Autoload + + eager_autoload do + autoload :CreateOne + autoload :CreateMany + autoload :CreateOneOfMany + + autoload :DeleteOne + autoload :DeleteMany + end + end +end diff --git a/activestorage/lib/active_storage/attached/changes/create_many.rb b/activestorage/lib/active_storage/attached/changes/create_many.rb new file mode 100644 index 0000000000..a7a8553e0f --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes/create_many.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module ActiveStorage + class Attached::Changes::CreateMany #:nodoc: + attr_reader :name, :record, :attachables + + def initialize(name, record, attachables) + @name, @record, @attachables = name, record, Array(attachables) + end + + def attachments + @attachments ||= subchanges.collect(&:attachment) + end + + def blobs + @blobs ||= subchanges.collect(&:blob) + end + + def upload + subchanges.each(&:upload) + end + + def save + assign_associated_attachments + reset_associated_blobs + end + + private + def subchanges + @subchanges ||= attachables.collect { |attachable| build_subchange_from(attachable) } + end + + def build_subchange_from(attachable) + ActiveStorage::Attached::Changes::CreateOneOfMany.new(name, record, attachable) + end + + + def assign_associated_attachments + record.public_send("#{name}_attachments=", attachments) + end + + def reset_associated_blobs + record.public_send("#{name}_blobs").reset + end + end +end diff --git a/activestorage/lib/active_storage/attached/changes/create_one.rb b/activestorage/lib/active_storage/attached/changes/create_one.rb new file mode 100644 index 0000000000..5812fd2b08 --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes/create_one.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "action_dispatch" +require "action_dispatch/http/upload" + +module ActiveStorage + class Attached::Changes::CreateOne #:nodoc: + attr_reader :name, :record, :attachable + + def initialize(name, record, attachable) + @name, @record, @attachable = name, record, attachable + end + + def attachment + @attachment ||= find_or_build_attachment + end + + def blob + @blob ||= find_or_build_blob + end + + def upload + case attachable + when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile + blob.upload_without_unfurling(attachable.open) + when Hash + blob.upload_without_unfurling(attachable.fetch(:io)) + end + end + + def save + record.public_send("#{name}_attachment=", attachment) + end + + private + def find_or_build_attachment + find_attachment || build_attachment + end + + def find_attachment + if record.public_send("#{name}_blob") == blob + record.public_send("#{name}_attachment") + end + end + + def build_attachment + ActiveStorage::Attachment.new(record: record, name: name, blob: blob) + end + + def find_or_build_blob + case attachable + when ActiveStorage::Blob + attachable + when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile + ActiveStorage::Blob.build_after_unfurling \ + io: attachable.open, + filename: attachable.original_filename, + content_type: attachable.content_type + when Hash + ActiveStorage::Blob.build_after_unfurling(attachable) + when String + ActiveStorage::Blob.find_signed(attachable) + else + raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}" + end + end + end +end diff --git a/activestorage/lib/active_storage/attached/changes/create_one_of_many.rb b/activestorage/lib/active_storage/attached/changes/create_one_of_many.rb new file mode 100644 index 0000000000..7268e87316 --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes/create_one_of_many.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActiveStorage + class Attached::Changes::CreateOneOfMany < Attached::Changes::CreateOne #:nodoc: + private + def find_attachment + record.public_send("#{name}_attachments").detect { |attachment| attachment.blob_id == blob.id } + end + end +end diff --git a/activestorage/lib/active_storage/attached/changes/delete_many.rb b/activestorage/lib/active_storage/attached/changes/delete_many.rb new file mode 100644 index 0000000000..6cbd1158dc --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes/delete_many.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActiveStorage + class Attached::Changes::DeleteMany #:nodoc: + attr_reader :name, :record + + def initialize(name, record) + @name, @record = name, record + end + + def attachments + ActiveStorage::Attachment.none + end + + def blobs + ActiveStorage::Blob.none + end + + def save + record.public_send("#{name}_attachments=", []) + end + end +end diff --git a/activestorage/lib/active_storage/attached/changes/delete_one.rb b/activestorage/lib/active_storage/attached/changes/delete_one.rb new file mode 100644 index 0000000000..2f7d356613 --- /dev/null +++ b/activestorage/lib/active_storage/attached/changes/delete_one.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ActiveStorage + class Attached::Changes::DeleteOne #:nodoc: + attr_reader :name, :record + + def initialize(name, record) + @name, @record = name, record + end + + def attachment + nil + end + + def save + record.public_send("#{name}_attachment=", nil) + end + end +end diff --git a/activestorage/lib/active_storage/attached/macros.rb b/activestorage/lib/active_storage/attached/macros.rb deleted file mode 100644 index 2b38a9b887..0000000000 --- a/activestorage/lib/active_storage/attached/macros.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -module ActiveStorage - # Provides the class-level DSL for declaring that an Active Record model has attached blobs. - module Attached::Macros - # Specifies the relation between a single attachment and the model. - # - # class User < ActiveRecord::Base - # 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. - def has_one_attached(name, dependent: :purge_later) - class_eval <<-CODE, __FILE__, __LINE__ + 1 - 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 - has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob - - scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) } - - if dependent == :purge_later - before_destroy { public_send(name).purge_later } - end - end - - # Specifies the relation between multiple attachments and the model. - # - # class Gallery < ActiveRecord::Base - # 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. - def has_many_attached(name, dependent: :purge_later) - class_eval <<-CODE, __FILE__, __LINE__ + 1 - 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" - has_many :"#{name}_blobs", through: :"#{name}_attachments", class_name: "ActiveStorage::Blob", source: :blob - - scope :"with_attached_#{name}", -> { includes("#{name}_attachments": :blob) } - - if dependent == :purge_later - before_destroy { public_send(name).purge_later } - end - end - end -end diff --git a/activestorage/lib/active_storage/attached/many.rb b/activestorage/lib/active_storage/attached/many.rb index 6eace65b79..25f88284df 100644 --- a/activestorage/lib/active_storage/attached/many.rb +++ b/activestorage/lib/active_storage/attached/many.rb @@ -9,22 +9,29 @@ module ActiveStorage # # All methods called on this proxy object that aren't listed here will automatically be delegated to +attachments+. def attachments - record.public_send("#{name}_attachments") + change.present? ? change.attachments : record.public_send("#{name}_attachments") end - # Associates one or several attachments with the current record, saving them to the database. + # Returns all attached blobs. + def blobs + 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 ]) def attach(*attachables) - attachables.flatten.collect do |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 + if record.persisted? && !record.changed? + record.update(name => blobs + attachables.flatten) + else + record.public_send("#{name}=", blobs + attachables.flatten) end end @@ -41,23 +48,18 @@ module ActiveStorage # Deletes associated attachments without purging them, leaving their respective blobs in place. def detach - attachments.destroy_all if attached? + 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). - def purge - if attached? - attachments.each(&:purge) - attachments.reload - end - end + ## + # :method: purge_later + # # Purges each associated attachment through the queuing system. - def purge_later - if attached? - attachments.each(&:purge_later) - end - end end end diff --git a/activestorage/lib/active_storage/attached/model.rb b/activestorage/lib/active_storage/attached/model.rb new file mode 100644 index 0000000000..ae7f0685f2 --- /dev/null +++ b/activestorage/lib/active_storage/attached/model.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module ActiveStorage + # Provides the class-level DSL for declaring an Active Record model's attachments. + module Attached::Model + extend ActiveSupport::Concern + + class_methods do + # Specifies the relation between a single attachment and the model. + # + # class User < ActiveRecord::Base + # 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. + def has_one_attached(name, dependent: :purge_later) + generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + @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 + + has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy + has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob + + scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) } + + after_save { attachment_changes[name.to_s]&.save } + + after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) } + + ActiveRecord::Reflection.add_attachment_reflection( + self, + name, + ActiveRecord::Reflection.create(:has_one_attached, name, nil, { dependent: dependent }, self) + ) + end + + # Specifies the relation between multiple attachments and the model. + # + # class Gallery < ActiveRecord::Base + # 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. + def has_many_attached(name, dependent: :purge_later) + generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + @active_storage_attached_#{name} ||= ActiveStorage::Attached::Many.new("#{name}", self) + end + + def #{name}=(attachables) + attachment_changes["#{name}"] = + if attachables.nil? || Array(attachables).none? + ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self) + else + ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables) + end + end + CODE + + has_many :"#{name}_attachments", -> { where(name: name) }, as: :record, class_name: "ActiveStorage::Attachment", inverse_of: :record, dependent: :destroy do + def purge + each(&:purge) + reset + end + + def purge_later + each(&:purge_later) + reset + end + end + has_many :"#{name}_blobs", through: :"#{name}_attachments", class_name: "ActiveStorage::Blob", source: :blob + + scope :"with_attached_#{name}", -> { includes("#{name}_attachments": :blob) } + + after_save { attachment_changes[name.to_s]&.save } + + after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) } + + ActiveRecord::Reflection.add_attachment_reflection( + self, + name, + ActiveRecord::Reflection.create(:has_many_attached, name, nil, { dependent: dependent }, self) + ) + end + end + + def attachment_changes #:nodoc: + @attachment_changes ||= {} + end + + def reload(*) #:nodoc: + super.tap { @attachment_changes = nil } + end + end +end diff --git a/activestorage/lib/active_storage/attached/one.rb b/activestorage/lib/active_storage/attached/one.rb index 0244232b2c..c039226fcd 100644 --- a/activestorage/lib/active_storage/attached/one.rb +++ b/activestorage/lib/active_storage/attached/one.rb @@ -10,20 +10,28 @@ module ActiveStorage # You don't have to call this method to access the attachment's methods as # they are all available at the model level. def attachment - record.public_send("#{name}_attachment") + change.present? ? change.attachment : record.public_send("#{name}_attachment") end - # Associates a given attachment with the current record, saving it to the database. + def blank? + !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 def attach(attachable) - if attached? && dependent == :purge_later - replace attachable + if record.persisted? && !record.changed? + record.update(name => attachable) else - write_attachment build_attachment_from(attachable) + record.public_send("#{name}=", attachable) end end @@ -41,7 +49,7 @@ module ActiveStorage # Deletes the attachment without purging it, leaving its blob in place. def detach if attached? - attachment.destroy + attachment.delete write_attachment nil end end @@ -59,23 +67,11 @@ module ActiveStorage def purge_later if attached? attachment.purge_later + write_attachment nil end end private - def replace(attachable) - blob.tap do - transaction do - detach - write_attachment build_attachment_from(attachable) - end - end.purge_later - end - - def build_attachment_from(attachable) - ActiveStorage::Attachment.new(record: record, name: name, blob: create_blob_from(attachable)) - end - def write_attachment(attachment) record.public_send("#{name}_attachment=", attachment) end diff --git a/activestorage/lib/active_storage/downloader.rb b/activestorage/lib/active_storage/downloader.rb new file mode 100644 index 0000000000..87be6efb05 --- /dev/null +++ b/activestorage/lib/active_storage/downloader.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActiveStorage + class Downloader #:nodoc: + def initialize(blob, tempdir: nil) + @blob = blob + @tempdir = tempdir + end + + def download_blob_to_tempfile + open_tempfile do |file| + download_blob_to file + verify_integrity_of file + yield file + end + end + + private + attr_reader :blob, :tempdir + + def open_tempfile + file = Tempfile.open([ "ActiveStorage-#{blob.id}-", blob.filename.extension_with_delimiter ], tempdir) + + begin + yield file + ensure + file.close! + end + end + + def download_blob_to(file) + file.binmode + blob.download { |chunk| file.write(chunk) } + file.flush + file.rewind + end + + def verify_integrity_of(file) + unless Digest::MD5.file(file).base64digest == blob.checksum + raise ActiveStorage::IntegrityError + end + end + end +end diff --git a/activestorage/lib/active_storage/downloading.rb b/activestorage/lib/active_storage/downloading.rb index 3dac6b116a..df820bc088 100644 --- a/activestorage/lib/active_storage/downloading.rb +++ b/activestorage/lib/active_storage/downloading.rb @@ -1,25 +1,46 @@ # 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: - Tempfile.open("ActiveStorage", tempdir) do |file| + 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: + 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: + def tempdir #:doc: Dir.tmpdir end end diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index b41d8bb4d7..9d6a27eabe 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -3,31 +3,58 @@ require "rails" require "active_storage" -require "active_storage/previewer/pdf_previewer" +require "active_storage/previewer/poppler_pdf_previewer" +require "active_storage/previewer/mupdf_previewer" require "active_storage/previewer/video_previewer" require "active_storage/analyzer/image_analyzer" require "active_storage/analyzer/video_analyzer" +require "active_storage/reflection" + 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.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ] config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ] - config.active_storage.variable_content_types = [ "image/png", "image/gif", "image/jpg", "image/jpeg", "image/vnd.adobe.photoshop" ] config.active_storage.paths = ActiveSupport::OrderedOptions.new + config.active_storage.variable_content_types = %w( + image/png + image/gif + image/jpg + image/jpeg + image/vnd.adobe.photoshop + image/vnd.microsoft.icon + ) + + 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 + ) + config.eager_load_namespaces << ActiveStorage initializer "active_storage.configs" do config.after_initialize do |app| - 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 || [] + ActiveStorage.logger = app.config.active_storage.logger || Rails.logger + ActiveStorage.queue = app.config.active_storage.queue + ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick + ActiveStorage.previewers = app.config.active_storage.previewers || [] + ActiveStorage.analyzers = app.config.active_storage.analyzers || [] + ActiveStorage.paths = app.config.active_storage.paths || {} + ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || [] + ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || [] + ActiveStorage.service_urls_expire_in = app.config.active_storage.service_urls_expire_in || 5.minutes end end @@ -35,7 +62,7 @@ module ActiveStorage require "active_storage/attached" ActiveSupport.on_load(:active_record) do - extend ActiveStorage::Attached::Macros + include ActiveStorage::Attached::Model end end @@ -46,7 +73,7 @@ module ActiveStorage end initializer "active_storage.services" do - config.to_prepare do + ActiveSupport.on_load(:active_storage_blob) do if config_choice = Rails.configuration.active_storage.service configs = Rails.configuration.active_storage.service_configurations ||= begin config_file = Pathname.new(Rails.root.join("config/storage.yml")) @@ -72,19 +99,10 @@ module ActiveStorage end end - initializer "active_storage.paths" do - config.after_initialize do |app| - if ffprobe_path = app.config.active_storage.paths.ffprobe - ActiveStorage::Analyzer::VideoAnalyzer.ffprobe_path = ffprobe_path - end - - if ffmpeg_path = app.config.active_storage.paths.ffmpeg - ActiveStorage::Previewer::VideoPreviewer.ffmpeg_path = ffmpeg_path - end - - if mutool_path = app.config.active_storage.paths.mutool - ActiveStorage::Previewer::PDFPreviewer.mutool_path = mutool_path - end + initializer "active_storage.reflection" do + ActiveSupport.on_load(:active_record) do + include Reflection::ActiveRecordExtensions + ActiveRecord::Reflection.singleton_class.prepend(Reflection::ReflectionExtension) end end end diff --git a/activestorage/lib/active_storage/errors.rb b/activestorage/lib/active_storage/errors.rb new file mode 100644 index 0000000000..f4bf66a615 --- /dev/null +++ b/activestorage/lib/active_storage/errors.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActiveStorage + # Generic base class for all Active Storage exceptions. + 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. + 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. + 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. + 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. + class IntegrityError < Error; end +end diff --git a/activestorage/lib/active_storage/gem_version.rb b/activestorage/lib/active_storage/gem_version.rb index f048bb0b77..492620731b 100644 --- a/activestorage/lib/active_storage/gem_version.rb +++ b/activestorage/lib/active_storage/gem_version.rb @@ -7,10 +7,10 @@ module ActiveStorage end module VERSION - MAJOR = 5 - MINOR = 2 + MAJOR = 6 + MINOR = 0 TINY = 0 - PRE = "beta2" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activestorage/lib/active_storage/log_subscriber.rb b/activestorage/lib/active_storage/log_subscriber.rb index a4e148c1a5..6c0b4c30e7 100644 --- a/activestorage/lib/active_storage/log_subscriber.rb +++ b/activestorage/lib/active_storage/log_subscriber.rb @@ -14,6 +14,8 @@ module ActiveStorage info event, color("Downloaded file from key: #{key_in(event)}", BLUE) end + alias_method :service_streaming_download, :service_download + def service_delete(event) info event, color("Deleted file from key: #{key_in(event)}", RED) end diff --git a/activestorage/lib/active_storage/previewer.rb b/activestorage/lib/active_storage/previewer.rb index 3d485988e9..95a041fd16 100644 --- a/activestorage/lib/active_storage/previewer.rb +++ b/activestorage/lib/active_storage/previewer.rb @@ -1,14 +1,10 @@ # 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. + # ActiveStorage::Previewer::MuPDFPreviewer 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 @@ -28,9 +24,14 @@ module ActiveStorage end private + # Downloads the blob to a tempfile on disk. Yields the tempfile. + def download_blob_to_tempfile(&block) #:doc: + blob.open tempdir: tempdir, &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 + # 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 @@ -41,22 +42,43 @@ module ActiveStorage # end # end # - # The output tempfile is opened in the directory returned by ActiveStorage::Downloading#tempdir. - def draw(*argv) # :doc: - Tempfile.open("ActiveStorage", tempdir) do |file| - capture(*argv, to: file) + # The output tempfile is opened in the directory returned by #tempdir. + def draw(*argv) #:doc: + open_tempfile do |file| + instrument :preview, key: blob.key do + capture(*argv, to: file) + end + yield file end end + def open_tempfile + tempfile = Tempfile.open("ActiveStorage-", tempdir) + + begin + yield tempfile + ensure + tempfile.close! + end + end + + def instrument(operation, payload = {}, &block) + ActiveSupport::Notifications.instrument "#{operation}.active_storage", payload, &block + end + def capture(*argv, to:) to.binmode - IO.popen(argv) { |out| IO.copy_stream(out, to) } + IO.popen(argv, err: File::NULL) { |out| IO.copy_stream(out, to) } to.rewind end - def logger + def logger #:doc: ActiveStorage.logger end + + def tempdir #:doc: + Dir.tmpdir + end end end diff --git a/activestorage/lib/active_storage/previewer/mupdf_previewer.rb b/activestorage/lib/active_storage/previewer/mupdf_previewer.rb new file mode 100644 index 0000000000..ae02a4889d --- /dev/null +++ b/activestorage/lib/active_storage/previewer/mupdf_previewer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ActiveStorage + class Previewer::MuPDFPreviewer < Previewer + class << self + def accept?(blob) + blob.content_type == "application/pdf" && mutool_exists? + end + + def mutool_path + ActiveStorage.paths[:mutool] || "mutool" + end + + 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 + + def preview + download_blob_to_tempfile do |input| + draw_first_page_from input do |output| + yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png" + end + end + end + + private + def draw_first_page_from(file, &block) + draw self.class.mutool_path, "draw", "-F", "png", "-o", "-", file.path, "1", &block + end + end +end diff --git a/activestorage/lib/active_storage/previewer/pdf_previewer.rb b/activestorage/lib/active_storage/previewer/pdf_previewer.rb deleted file mode 100644 index b84aefcc9c..0000000000 --- a/activestorage/lib/active_storage/previewer/pdf_previewer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ActiveStorage - class Previewer::PDFPreviewer < Previewer - class_attribute :mutool_path, default: "mutool" - - def self.accept?(blob) - blob.content_type == "application/pdf" - end - - def preview - download_blob_to_tempfile do |input| - draw_first_page_from input do |output| - yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png" - end - end - end - - private - def draw_first_page_from(file, &block) - draw mutool_path, "draw", "-F", "png", "-o", "-", file.path, "1", &block - end - end -end diff --git a/activestorage/lib/active_storage/previewer/poppler_pdf_previewer.rb b/activestorage/lib/active_storage/previewer/poppler_pdf_previewer.rb new file mode 100644 index 0000000000..69eb617d7b --- /dev/null +++ b/activestorage/lib/active_storage/previewer/poppler_pdf_previewer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActiveStorage + class Previewer::PopplerPDFPreviewer < Previewer + class << self + def accept?(blob) + blob.content_type == "application/pdf" && pdftoppm_exists? + end + + def pdftoppm_path + ActiveStorage.paths[:pdftoppm] || "pdftoppm" + end + + def pdftoppm_exists? + return @pdftoppm_exists if defined?(@pdftoppm_exists) + + @pdftoppm_exists = system(pdftoppm_path, "-v", out: File::NULL, err: File::NULL) + end + end + + def preview + download_blob_to_tempfile do |input| + draw_first_page_from input do |output| + yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png" + end + end + end + + private + def draw_first_page_from(file, &block) + # use 72 dpi to match thumbnail dimesions of the PDF + draw self.class.pdftoppm_path, "-singlefile", "-r", "72", "-png", file.path, &block + end + end +end diff --git a/activestorage/lib/active_storage/previewer/video_previewer.rb b/activestorage/lib/active_storage/previewer/video_previewer.rb index 5d06e33f44..50e13d202a 100644 --- a/activestorage/lib/active_storage/previewer/video_previewer.rb +++ b/activestorage/lib/active_storage/previewer/video_previewer.rb @@ -2,8 +2,6 @@ module ActiveStorage class Previewer::VideoPreviewer < Previewer - class_attribute :ffmpeg_path, default: "ffmpeg" - def self.accept?(blob) blob.video? end @@ -11,15 +9,18 @@ module ActiveStorage def preview 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" + yield io: output, filename: "#{blob.filename.base}.jpg", content_type: "image/jpeg" end end end private def draw_relevant_frame_from(file, &block) - draw ffmpeg_path, "-i", file.path, "-y", "-vcodec", "png", - "-vf", "thumbnail", "-vframes", "1", "-f", "image2", "-", &block + draw ffmpeg_path, "-i", file.path, "-y", "-vframes", "1", "-f", "image2", "-", &block + end + + def ffmpeg_path + ActiveStorage.paths[:ffmpeg] || "ffmpeg" end end end diff --git a/activestorage/lib/active_storage/reflection.rb b/activestorage/lib/active_storage/reflection.rb new file mode 100644 index 0000000000..ce248c88b5 --- /dev/null +++ b/activestorage/lib/active_storage/reflection.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module ActiveStorage + module Reflection + # Holds all the metadata about a has_one_attached attachment as it was + # specified in the Active Record class. + class HasOneAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc: + def macro + :has_one_attached + end + end + + # Holds all the metadata about a has_many_attached attachment as it was + # specified in the Active Record class. + class HasManyAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc: + def macro + :has_many_attached + end + end + + module ReflectionExtension # :nodoc: + def add_attachment_reflection(model, name, reflection) + model.attachment_reflections = model.attachment_reflections.merge(name.to_s => reflection) + end + + private + def reflection_class_for(macro) + case macro + when :has_one_attached + HasOneAttachedReflection + when :has_many_attached + HasManyAttachedReflection + else + super + end + end + end + + module ActiveRecordExtensions + extend ActiveSupport::Concern + + included do + class_attribute :attachment_reflections, instance_writer: false, default: {} + end + + module ClassMethods + # Returns an array of reflection objects for all the attachments in the + # class. + def reflect_on_all_attachments + attachment_reflections.values + end + + # Returns the reflection object for the named +attachment+. + # + # User.reflect_on_attachment(:avatar) + # # => the avatar reflection + # + def reflect_on_attachment(attachment) + attachment_reflections[attachment.to_s] + end + end + end + end +end diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index f2e1269f27..f915518f52 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -3,8 +3,6 @@ require "active_storage/log_subscriber" module ActiveStorage - class IntegrityError < StandardError; end - # Abstract class serving as an interface for concrete services. # # The available services are: @@ -41,8 +39,6 @@ module ActiveStorage extend ActiveSupport::Autoload autoload :Configurator - class_attribute :url_expires_in, default: 5.minutes - 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 @@ -73,6 +69,11 @@ module ActiveStorage raise NotImplementedError end + # Return the partial content in the byte +range+ of the file at the +key+. + def download_chunk(key, range) + raise NotImplementedError + end + # Delete the file at the +key+. def delete(key) raise NotImplementedError @@ -89,7 +90,7 @@ module ActiveStorage end # Returns a signed, temporary URL for the file at the +key+. The URL will be valid for the amount - # of seconds specified in +expires_in+. You most also provide the +disposition+ (+:inline+ or +:attachment+), + # of seconds specified in +expires_in+. You must also provide the +disposition+ (+:inline+ or +:attachment+), # +filename+, and +content_type+ that you wish the file to be served with on request. def url(key, expires_in:, disposition:, filename:, content_type:) raise NotImplementedError diff --git a/activestorage/lib/active_storage/service/azure_storage_service.rb b/activestorage/lib/active_storage/service/azure_storage_service.rb index 0a9eb7f23d..b26234c722 100644 --- a/activestorage/lib/active_storage/service/azure_storage_service.rb +++ b/activestorage/lib/active_storage/service/azure_storage_service.rb @@ -8,20 +8,19 @@ 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, :path, :blobs, :container, :signer + attr_reader :client, :blobs, :container, :signer - def initialize(path:, storage_account_name:, storage_access_key:, container:) + def initialize(storage_account_name:, storage_access_key:, container:) @client = Azure::Storage::Client.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key) @signer = Azure::Storage::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key) @blobs = client.blob_client @container = container - @path = path end def upload(key, io, checksum: nil) instrument :upload, key: key, checksum: checksum do begin - blobs.create_block_blob(container, key, io, content_md5: checksum) + blobs.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum) rescue Azure::Core::Http::HTTPError raise ActiveStorage::IntegrityError end @@ -41,6 +40,13 @@ module ActiveStorage end end + def download_chunk(key, range) + instrument :download_chunk, key: key, range: range do + _, io = blobs.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 + def delete(key) instrument :delete, key: key do begin @@ -77,9 +83,9 @@ module ActiveStorage def url(key, expires_in:, filename:, disposition:, content_type:) instrument :url, key: key do |payload| - base_url = url_for(key) generated_url = signer.signed_uri( - URI(base_url), false, + uri_for(key), false, + service: "b", permissions: "r", expiry: format_expiry(expires_in), content_disposition: content_disposition_with(type: disposition, filename: filename), @@ -94,9 +100,12 @@ module ActiveStorage def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:) instrument :url, key: key do |payload| - base_url = url_for(key) - generated_url = signer.signed_uri(URI(base_url), false, permissions: "rw", - expiry: format_expiry(expires_in)).to_s + generated_url = signer.signed_uri( + uri_for(key), false, + service: "b", + permissions: "rw", + expiry: format_expiry(expires_in) + ).to_s payload[:url] = generated_url @@ -109,8 +118,8 @@ module ActiveStorage end private - def url_for(key) - "#{path}/#{container}/#{key}" + def uri_for(key) + blobs.generate_uri("#{container}/#{key}") end def blob_for(key) diff --git a/activestorage/lib/active_storage/service/configurator.rb b/activestorage/lib/active_storage/service/configurator.rb index 39951fd026..fa80c66c3b 100644 --- a/activestorage/lib/active_storage/service/configurator.rb +++ b/activestorage/lib/active_storage/service/configurator.rb @@ -26,7 +26,9 @@ module ActiveStorage def resolve(class_name) require "active_storage/service/#{class_name.to_s.underscore}_service" - ActiveStorage::Service.const_get(:"#{class_name}Service") + ActiveStorage::Service.const_get(:"#{class_name.camelize}Service") + rescue LoadError + raise "Missing service adapter for #{class_name.inspect}" end end end diff --git a/activestorage/lib/active_storage/service/disk_service.rb b/activestorage/lib/active_storage/service/disk_service.rb index a8728c5bc3..9f304b7e01 100644 --- a/activestorage/lib/active_storage/service/disk_service.rb +++ b/activestorage/lib/active_storage/service/disk_service.rb @@ -26,7 +26,7 @@ module ActiveStorage if block_given? instrument :streaming_download, key: key do File.open(path_for(key), "rb") do |file| - while data = file.read(64.kilobytes) + while data = file.read(5.megabytes) yield data end end @@ -38,6 +38,15 @@ module ActiveStorage end end + def download_chunk(key, range) + instrument :download_chunk, key: key, range: range do + File.open(path_for(key), "rb") do |file| + file.seek range.begin + file.read range.size + end + end + end + def delete(key) instrument :delete, key: key do begin @@ -69,14 +78,13 @@ module ActiveStorage verified_key_with_expiration = ActiveStorage.verifier.generate(key, expires_in: expires_in, purpose: :blob_key) generated_url = - if defined?(Rails.application) - Rails.application.routes.url_helpers.rails_disk_service_path \ - verified_key_with_expiration, - filename: filename, disposition: content_disposition_with(type: disposition, filename: filename), content_type: content_type - else - "/rails/active_storage/disk/#{verified_key_with_expiration}/#{filename}?content_type=#{content_type}" \ - "&disposition=#{content_disposition_with(type: disposition, filename: filename)}" - end + url_helpers.rails_disk_service_url( + verified_key_with_expiration, + host: current_host, + filename: filename, + disposition: content_disposition_with(type: disposition, filename: filename), + content_type: content_type + ) payload[:url] = generated_url @@ -97,12 +105,7 @@ module ActiveStorage purpose: :blob_token } ) - generated_url = - if defined?(Rails.application) - Rails.application.routes.url_helpers.update_rails_disk_service_path verified_token_with_expiration - else - "/rails/active_storage/disk/#{verified_token_with_expiration}" - end + generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host) payload[:url] = generated_url @@ -114,11 +117,11 @@ module ActiveStorage { "Content-Type" => content_type } end - private - def path_for(key) - File.join root, folder_for(key), key - end + def path_for(key) #:nodoc: + File.join root, folder_for(key), key + end + private def folder_for(key) [ key[0..1], key[2..3] ].join("/") end @@ -133,5 +136,13 @@ module ActiveStorage raise ActiveStorage::IntegrityError end end + + def url_helpers + @url_helpers ||= Rails.application.routes.url_helpers + end + + def current_host + ActiveStorage::Current.host + end end end diff --git a/activestorage/lib/active_storage/service/gcs_service.rb b/activestorage/lib/active_storage/service/gcs_service.rb index 6f6f4105fe..eb46973509 100644 --- a/activestorage/lib/active_storage/service/gcs_service.rb +++ b/activestorage/lib/active_storage/service/gcs_service.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -gem "google-cloud-storage", "~> 1.8" - +gem "google-cloud-storage", "~> 1.11" require "google/cloud/storage" -require "active_support/core_ext/object/to_query" module ActiveStorage # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API @@ -29,20 +27,24 @@ module ActiveStorage end end - # FIXME: Download in chunks when given a block. - def download(key) - instrument :download, key: key do - io = file_for(key).download - io.rewind - - if block_given? - yield io.read - else - io.read + 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 end end end + def download_chunk(key, range) + instrument :download_chunk, key: key, range: range do + file_for(key).download(range: range).string + end + end + def delete(key) instrument :delete, key: key do begin @@ -55,7 +57,13 @@ module ActiveStorage def delete_prefixed(prefix) instrument :delete_prefixed, prefix: prefix do - bucket.files(prefix: prefix).all(&:delete) + bucket.files(prefix: prefix).all do |file| + begin + file.delete + rescue Google::Cloud::NotFoundError + # Ignore concurrently-deleted files + end + end end end @@ -80,10 +88,9 @@ module ActiveStorage end end - def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:) + 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_type: content_type, content_md5: checksum + generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum payload[:url] = generated_url @@ -91,15 +98,28 @@ module ActiveStorage end end - def headers_for_direct_upload(key, content_type:, checksum:, **) - { "Content-Type" => content_type, "Content-MD5" => checksum } + def headers_for_direct_upload(key, checksum:, **) + { "Content-MD5" => checksum } end private attr_reader :config - def file_for(key) - bucket.file(key, skip_lookup: true) + 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 + + while offset < file.size + yield file.download(range: offset..(offset + chunk_size - 1)).string + offset += chunk_size + end end def bucket diff --git a/activestorage/lib/active_storage/service/mirror_service.rb b/activestorage/lib/active_storage/service/mirror_service.rb index 7eca8ce7f4..6002ef5a00 100644 --- a/activestorage/lib/active_storage/service/mirror_service.rb +++ b/activestorage/lib/active_storage/service/mirror_service.rb @@ -9,7 +9,7 @@ module ActiveStorage class Service::MirrorService < Service attr_reader :primary, :mirrors - delegate :download, :exist?, :url, to: :primary + delegate :download, :download_chunk, :exist?, :url, to: :primary # Stitch together from named services. def self.build(primary:, mirrors:, configurator:, **options) #:nodoc: diff --git a/activestorage/lib/active_storage/service/s3_service.rb b/activestorage/lib/active_storage/service/s3_service.rb index c95672f338..0286e7ff21 100644 --- a/activestorage/lib/active_storage/service/s3_service.rb +++ b/activestorage/lib/active_storage/service/s3_service.rb @@ -9,8 +9,8 @@ module ActiveStorage class Service::S3Service < Service attr_reader :client, :bucket, :upload_options - def initialize(access_key_id:, secret_access_key:, region:, bucket:, upload: {}, **options) - @client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region, **options) + def initialize(bucket:, upload: {}, **options) + @client = Aws::S3::Resource.new(**options) @bucket = @client.bucket(bucket) @upload_options = upload @@ -33,11 +33,17 @@ module ActiveStorage end else instrument :download, key: key do - object_for(key).get.body.read.force_encoding(Encoding::BINARY) + object_for(key).get.body.string.force_encoding(Encoding::BINARY) 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.read.force_encoding(Encoding::BINARY) + end + end + def delete(key) instrument :delete, key: key do object_for(key).delete diff --git a/activestorage/lib/active_storage/transformers/image_processing_transformer.rb b/activestorage/lib/active_storage/transformers/image_processing_transformer.rb new file mode 100644 index 0000000000..7f8685b72d --- /dev/null +++ b/activestorage/lib/active_storage/transformers/image_processing_transformer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "image_processing" + +module ActiveStorage + module Transformers + class ImageProcessingTransformer < Transformer + private + def process(file, format:) + processor. + source(file). + loader(page: 0). + convert(format). + apply(operations). + call + end + + def processor + ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize) + end + + def operations + transformations.each_with_object([]) do |(name, argument), list| + if name.to_s == "combine_options" + ActiveSupport::Deprecation.warn <<~WARNING + 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 + + list.concat argument.keep_if { |key, value| value.present? }.to_a + elsif argument.present? + list << [ name, argument ] + end + end + end + end + end +end diff --git a/activestorage/lib/active_storage/transformers/mini_magick_transformer.rb b/activestorage/lib/active_storage/transformers/mini_magick_transformer.rb new file mode 100644 index 0000000000..e8e99cea9e --- /dev/null +++ b/activestorage/lib/active_storage/transformers/mini_magick_transformer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "mini_magick" + +module ActiveStorage + module Transformers + class MiniMagickTransformer < Transformer + private + def process(file, format:) + image = MiniMagick::Image.new(file.path, file) + + transformations.each do |name, argument_or_subtransformations| + image.mogrify do |command| + if name.to_s == "combine_options" + argument_or_subtransformations.each do |subtransformation_name, subtransformation_argument| + pass_transform_argument(command, subtransformation_name, subtransformation_argument) + end + else + pass_transform_argument(command, name, argument_or_subtransformations) + end + end + end + + image.format(format) if format + + image.tempfile.tap(&:open) + end + + def pass_transform_argument(command, method, argument) + if argument == true + command.public_send(method) + elsif argument.present? + command.public_send(method, argument) + end + end + end + end +end diff --git a/activestorage/lib/active_storage/transformers/transformer.rb b/activestorage/lib/active_storage/transformers/transformer.rb new file mode 100644 index 0000000000..2e21201004 --- /dev/null +++ b/activestorage/lib/active_storage/transformers/transformer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ActiveStorage + 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 + class Transformer + attr_reader :transformations + + def initialize(transformations) + @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. + def transform(file, format:) + output = process(file, format: format) + + begin + yield output + ensure + output.close! + end + end + + private + # Returns an open Tempfile containing a transformed image in the given +format+. + # All subclasses implement this method. + def process(file, format:) #:doc: + raise NotImplementedError + end + end + end +end |