Skip to content

Commit

Permalink
[Analyzer] Refacto our image analyzers to further expand the gem (#254)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mth0158 committed Nov 24, 2024
1 parent af50741 commit 521f837
Show file tree
Hide file tree
Showing 29 changed files with 613 additions and 263 deletions.
6 changes: 6 additions & 0 deletions lib/active_storage_validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
require 'active_model'
require 'active_support/concern'

require 'active_storage_validations/analyzer'
require 'active_storage_validations/analyzer/image_analyzer'
require 'active_storage_validations/analyzer/image_analyzer/image_magick'
require 'active_storage_validations/analyzer/image_analyzer/vips'
require 'active_storage_validations/analyzer/null_analyzer'

require 'active_storage_validations/railtie'
require 'active_storage_validations/engine'
require 'active_storage_validations/attached_validator'
Expand Down
58 changes: 58 additions & 0 deletions lib/active_storage_validations/analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

require_relative 'shared/asv_attachable'
require_relative 'shared/asv_loggable'

module ActiveStorageValidations
# = Active Storage Validations \Analyzer
#
# This is an abstract base class for analyzers, which extract metadata from attachables.
# See ActiveStorageValidations::Analyzer::ImageAnalyzer for an example of a concrete subclass.
#
# Heavily (not to say 100%) inspired by Rails own ActiveStorage::Analyzer
class Analyzer
include ASVAttachable
include ASVLoggable

attr_reader :attachable

# Implement this method in a concrete subclass. Have it return true when given an attachable from which
# the analyzer can extract metadata.
def self.accept?(attachable)
false
end

# Returns true if the attachable media_type matches, like image?(attachable) returns
# true for 'image/png'
class << self
%w[
image
audio
video
].each do |media_type|
define_method(:"#{media_type}?") do |attachable|
attachable_content_type(attachable).start_with?(media_type)
end
end

def attachable_content_type(attachable)
new(attachable).send(:attachable_content_type, attachable)
end
end

def initialize(attachable)
@attachable = attachable
end

# Override this method in a concrete subclass. Have it return a Hash of metadata.
def metadata
raise NotImplementedError
end

private

def instrument(analyzer, &block)
ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
end
end
end
81 changes: 81 additions & 0 deletions lib/active_storage_validations/analyzer/image_analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

module ActiveStorageValidations
# = Active Storage Image \Analyzer
#
# This is an abstract base class for image analyzers, which extract width and height from an image attachable.
#
# 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::ImageMagick.new(attachable).metadata
# # => { width: 4104, height: 2736 }
class Analyzer::ImageAnalyzer < Analyzer
def self.accept?(attachable)
image?(attachable)
end

def metadata
read_image do |image|
if rotated_image?(image)
{ width: image.height, height: image.width }
else
{ width: image.width, height: image.height }
end
end
end

private

def image
case @attachable
when ActiveStorage::Blob, String
blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
tempfile = tempfile_from_blob(blob)

image_from_path(tempfile.path)
when Hash
io = @attachable[:io]
file = io.is_a?(StringIO) ? tempfile_from_io(io) : File.open(io)

image_from_path(file.path)
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, File
image_from_path(@attachable.path)
when Pathname
image_from_path(@attachable.to_s)
else
raise_rails_like_error(@attachable)
end
end

def tempfile_from_blob(blob)
tempfile = Tempfile.new(["ActiveStorage-#{blob.id}-", blob.filename.extension_with_delimiter], binmode: true)

blob.download { |chunk| tempfile.write(chunk) }

tempfile.flush
tempfile.rewind
tempfile
end

def tempfile_from_io(io)
tempfile = Tempfile.new([File.basename(@attachable[:filename], '.*'), File.extname(@attachable[:filename])], binmode: true)

IO.copy_stream(io, tempfile)
io.rewind

tempfile.flush
tempfile.rewind
tempfile
end

def image_from_path(path)
raise NotImplementedError
end

def rotated_image?(image)
raise NotImplementedError
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module ActiveStorageValidations
# This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem.
# MiniMagick requires the {ImageMagick}[http://www.imagemagick.org] system library.
# This is the default Rails image analyzer.
class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer

private

def read_image
begin
require "mini_magick"
rescue LoadError
logger.info "Skipping image analysis because the mini_magick gem isn't installed"
return {}
end

if image.valid?
yield image
else
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
{}
end
rescue MiniMagick::Error => error
logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
{}
end

def image_from_path(path)
instrument("mini_magick") do
MiniMagick::Image.new(path)
end
end

def rotated_image?(image)
%w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
end
end
end
51 changes: 51 additions & 0 deletions lib/active_storage_validations/analyzer/image_analyzer/vips.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

module ActiveStorageValidations
# This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem.
# Ruby-vips requires the {libvips}[https://libvips.github.io/libvips/] system library.
class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer

private

def read_image
begin
require "ruby-vips"
rescue LoadError
logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
return {}
end

if image
yield image
else
logger.info "Skipping image analysis because Vips doesn't support the file"
{}
end
rescue ::Vips::Error => error
logger.error "Skipping image analysis due to a Vips error: #{error.message}"
{}
end

def image_from_path(path)
instrument("vips") do
begin
::Vips::Image.new_from_file(path, access: :sequential)
rescue ::Vips::Error
# Vips throw errors rather than returning false when reading a not
# supported attachable.
# We stumbled upon this issue while reading 0 byte size attachable
# https://github.com/janko/image_processing/issues/97
logger.info "Skipping image analysis because Vips doesn't support the file"
nil
end
end
end

ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
def rotated_image?(image)
ROTATIONS === image.get("exif-ifd0-Orientation")
rescue ::Vips::Error
false
end
end
end
22 changes: 22 additions & 0 deletions lib/active_storage_validations/analyzer/null_analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module ActiveStorageValidations
# = Active Storage Null Analyzer
#
# This is a fallback analyzer when the attachable media type is not supported
# by our gem.
#
# Example:
#
# ActiveStorage::Analyzer::NullAnalyzer.new(attachable).metadata
# # => {}
class Analyzer::NullAnalyzer < Analyzer
def self.accept?(attachable)
true
end

def metadata
{}
end
end
end
4 changes: 3 additions & 1 deletion lib/active_storage_validations/aspect_ratio_validator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative 'shared/asv_active_storageable'
require_relative 'shared/asv_analyzable'
require_relative 'shared/asv_attachable'
require_relative 'shared/asv_errorable'
require_relative 'shared/asv_optionable'
Expand All @@ -9,6 +10,7 @@
module ActiveStorageValidations
class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
include ASVActiveStorageable
include ASVAnalyzable
include ASVAttachable
include ASVErrorable
include ASVOptionable
Expand Down Expand Up @@ -53,7 +55,7 @@ def is_valid?(record, attribute, attachable, metadata)
end

def image_metadata_missing?(record, attribute, attachable, flat_options, metadata)
return false if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
return false if metadata.present? && metadata[:width].to_i > 0 && metadata[:height].to_i > 0

errors_options = initialize_error_options(options, attachable)
errors_options[:aspect_ratio] = flat_options[:with]
Expand Down
2 changes: 2 additions & 0 deletions lib/active_storage_validations/content_type_spoof_detector.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require_relative 'shared/asv_analyzable'
require_relative 'shared/asv_attachable'
require_relative 'shared/asv_loggable'
require 'open3'
Expand All @@ -8,6 +9,7 @@ module ActiveStorageValidations
class ContentTypeSpoofDetector
class FileCommandLineToolNotInstalledError < StandardError; end

include ASVAnalyzable
include ASVAttachable
include ASVLoggable

Expand Down
2 changes: 2 additions & 0 deletions lib/active_storage_validations/content_type_validator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative 'shared/asv_active_storageable'
require_relative 'shared/asv_analyzable'
require_relative 'shared/asv_attachable'
require_relative 'shared/asv_errorable'
require_relative 'shared/asv_optionable'
Expand All @@ -10,6 +11,7 @@
module ActiveStorageValidations
class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
include ASVActiveStorageable
include ASVAnalyzable
include ASVAttachable
include ASVErrorable
include ASVOptionable
Expand Down
2 changes: 2 additions & 0 deletions lib/active_storage_validations/dimension_validator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative 'shared/asv_active_storageable'
require_relative 'shared/asv_analyzable'
require_relative 'shared/asv_attachable'
require_relative 'shared/asv_errorable'
require_relative 'shared/asv_optionable'
Expand All @@ -9,6 +10,7 @@
module ActiveStorageValidations
class DimensionValidator < ActiveModel::EachValidator # :nodoc
include ASVActiveStorageable
include ASVAnalyzable
include ASVAttachable
include ASVErrorable
include ASVOptionable
Expand Down
3 changes: 2 additions & 1 deletion lib/active_storage_validations/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def self.stub_method(object, method, result)

def self.mock_metadata(attachment, width, height)
mock = Struct.new(:metadata).new({ width: width, height: height })
stub_method(ActiveStorageValidations::Metadata, :new, mock) do

stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
yield
end
end
Expand Down
Loading

0 comments on commit 521f837

Please sign in to comment.