Skip to content

Commit 7f76ecb

Browse files
committed
[Analyzer] Refacto our image analyzers to further expand the gem (#254)
1 parent af50741 commit 7f76ecb

29 files changed

+621
-263
lines changed

lib/active_storage_validations.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
require 'active_model'
44
require 'active_support/concern'
55

6+
require 'active_storage_validations/analyzer'
7+
require 'active_storage_validations/analyzer/image_analyzer'
8+
require 'active_storage_validations/analyzer/image_analyzer/image_magick'
9+
require 'active_storage_validations/analyzer/image_analyzer/vips'
10+
require 'active_storage_validations/analyzer/null_analyzer'
11+
612
require 'active_storage_validations/railtie'
713
require 'active_storage_validations/engine'
814
require 'active_storage_validations/attached_validator'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'shared/asv_attachable'
4+
require_relative 'shared/asv_loggable'
5+
6+
module ActiveStorageValidations
7+
# = Active Storage Validations \Analyzer
8+
#
9+
# This is an abstract base class for analyzers, which extract metadata from attachables.
10+
# See ActiveStorageValidations::Analyzer::ImageAnalyzer for an example of a concrete subclass.
11+
#
12+
# Heavily (not to say 100%) inspired by Rails own ActiveStorage::Analyzer
13+
class Analyzer
14+
include ASVAttachable
15+
include ASVLoggable
16+
17+
attr_reader :attachable
18+
19+
# Implement this method in a concrete subclass. Have it return true when given an attachable from which
20+
# the analyzer can extract metadata.
21+
def self.accept?(attachable)
22+
false
23+
end
24+
25+
# Returns true if the attachable media_type matches, like image?(attachable) returns
26+
# true for 'image/png'
27+
class << self
28+
%w[
29+
image
30+
audio
31+
video
32+
].each do |media_type|
33+
define_method(:"#{media_type}?") do |attachable|
34+
attachable_content_type(attachable).start_with?(media_type)
35+
end
36+
end
37+
38+
def attachable_content_type(attachable)
39+
new(attachable).send(:attachable_content_type, attachable)
40+
end
41+
end
42+
43+
def initialize(attachable)
44+
@attachable = attachable
45+
end
46+
47+
# Override this method in a concrete subclass. Have it return a Hash of metadata.
48+
def metadata
49+
raise NotImplementedError
50+
end
51+
52+
private
53+
54+
def instrument(analyzer, &block)
55+
ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
56+
end
57+
end
58+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveStorageValidations
4+
# = Active Storage Image \Analyzer
5+
#
6+
# This is an abstract base class for image analyzers, which extract width and height from an image attachable.
7+
#
8+
# If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience.
9+
#
10+
# Example:
11+
#
12+
# ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
13+
# # => { width: 4104, height: 2736 }
14+
class Analyzer::ImageAnalyzer < Analyzer
15+
def self.accept?(attachable)
16+
image?(attachable)
17+
end
18+
19+
def metadata
20+
read_image do |image|
21+
if rotated_image?(image)
22+
{ width: image.height, height: image.width }
23+
else
24+
{ width: image.width, height: image.height }
25+
end
26+
end
27+
end
28+
29+
private
30+
31+
def image
32+
case @attachable
33+
when ActiveStorage::Blob, String
34+
blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
35+
tempfile = tempfile_from_blob(blob)
36+
37+
image_from_path(tempfile.path)
38+
when Hash
39+
io = @attachable[:io]
40+
file = io.is_a?(StringIO) ? tempfile_from_io(io) : File.open(io)
41+
42+
image_from_path(file.path)
43+
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, File
44+
image_from_path(@attachable.path)
45+
when Pathname
46+
image_from_path(@attachable.to_s)
47+
else
48+
raise_rails_like_error(@attachable)
49+
end
50+
end
51+
52+
def tempfile_from_blob(blob)
53+
tempfile = Tempfile.new(["ActiveStorage-#{blob.id}-", blob.filename.extension_with_delimiter], binmode: true)
54+
55+
blob.download { |chunk| tempfile.write(chunk) }
56+
57+
tempfile.flush
58+
tempfile.rewind
59+
tempfile
60+
end
61+
62+
def tempfile_from_io(io)
63+
tempfile = Tempfile.new([File.basename(@attachable[:filename], '.*'), File.extname(@attachable[:filename])], binmode: true)
64+
65+
IO.copy_stream(io, tempfile)
66+
io.rewind
67+
68+
tempfile.flush
69+
tempfile.rewind
70+
tempfile
71+
end
72+
73+
def image_from_path(path)
74+
raise NotImplementedError
75+
end
76+
77+
def rotated_image?(image)
78+
raise NotImplementedError
79+
end
80+
end
81+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveStorageValidations
4+
# This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem.
5+
# MiniMagick requires the {ImageMagick}[http://www.imagemagick.org] system library.
6+
# This is the default Rails image analyzer.
7+
class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer
8+
9+
private
10+
11+
def read_image
12+
begin
13+
require "mini_magick"
14+
rescue LoadError
15+
logger.info "Skipping image analysis because the mini_magick gem isn't installed"
16+
return {}
17+
end
18+
19+
if image.valid?
20+
yield image
21+
else
22+
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
23+
{}
24+
end
25+
rescue MiniMagick::Error => error
26+
logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
27+
{}
28+
end
29+
30+
def image_from_path(path)
31+
instrument("mini_magick") do
32+
MiniMagick::Image.new(path)
33+
end
34+
end
35+
36+
def rotated_image?(image)
37+
%w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
38+
end
39+
end
40+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveStorageValidations
4+
# This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem.
5+
# Ruby-vips requires the {libvips}[https://libvips.github.io/libvips/] system library.
6+
class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer
7+
MockInvalidImage = Struct.new(:invalid_image) do
8+
def valid?
9+
false
10+
end
11+
end
12+
13+
private
14+
15+
def read_image
16+
begin
17+
require "ruby-vips"
18+
rescue LoadError
19+
logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
20+
return {}
21+
end
22+
23+
if image.valid?
24+
yield image
25+
else
26+
logger.info "Skipping image analysis because Vips doesn't support the file"
27+
{}
28+
end
29+
rescue ::Vips::Error => error
30+
logger.error "Skipping image analysis due to a Vips error: #{error.message}"
31+
{}
32+
end
33+
34+
def image_from_path(path)
35+
instrument("vips") do
36+
begin
37+
::Vips::Image.new_from_file(path, access: :sequential)
38+
rescue ::Vips::Error
39+
# We handle cases where an error is raised when reading the attachable
40+
# because Vips can throw errors rather than returning false.
41+
# We stumbled upon this issue while reading 0 byte size attachable
42+
# https://github.com/janko/image_processing/issues/97
43+
invalid_image
44+
end
45+
end
46+
end
47+
48+
def invalid_image
49+
MockInvalidImage.new
50+
end
51+
52+
ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
53+
def rotated_image?(image)
54+
ROTATIONS === image.get("exif-ifd0-Orientation")
55+
rescue ::Vips::Error
56+
false
57+
end
58+
end
59+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveStorageValidations
4+
# = Active Storage Null Analyzer
5+
#
6+
# This is a fallback analyzer when the attachable media type is not supported
7+
# by our gem.
8+
#
9+
# Example:
10+
#
11+
# ActiveStorage::Analyzer::NullAnalyzer.new(attachable).metadata
12+
# # => {}
13+
class Analyzer::NullAnalyzer < Analyzer
14+
def self.accept?(attachable)
15+
true
16+
end
17+
18+
def metadata
19+
{}
20+
end
21+
end
22+
end

lib/active_storage_validations/aspect_ratio_validator.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative 'shared/asv_active_storageable'
4+
require_relative 'shared/asv_analyzable'
45
require_relative 'shared/asv_attachable'
56
require_relative 'shared/asv_errorable'
67
require_relative 'shared/asv_optionable'
@@ -9,6 +10,7 @@
910
module ActiveStorageValidations
1011
class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
1112
include ASVActiveStorageable
13+
include ASVAnalyzable
1214
include ASVAttachable
1315
include ASVErrorable
1416
include ASVOptionable
@@ -53,7 +55,7 @@ def is_valid?(record, attribute, attachable, metadata)
5355
end
5456

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

5860
errors_options = initialize_error_options(options, attachable)
5961
errors_options[:aspect_ratio] = flat_options[:with]

lib/active_storage_validations/content_type_spoof_detector.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative 'shared/asv_analyzable'
34
require_relative 'shared/asv_attachable'
45
require_relative 'shared/asv_loggable'
56
require 'open3'
@@ -8,6 +9,7 @@ module ActiveStorageValidations
89
class ContentTypeSpoofDetector
910
class FileCommandLineToolNotInstalledError < StandardError; end
1011

12+
include ASVAnalyzable
1113
include ASVAttachable
1214
include ASVLoggable
1315

lib/active_storage_validations/content_type_validator.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative 'shared/asv_active_storageable'
4+
require_relative 'shared/asv_analyzable'
45
require_relative 'shared/asv_attachable'
56
require_relative 'shared/asv_errorable'
67
require_relative 'shared/asv_optionable'
@@ -10,6 +11,7 @@
1011
module ActiveStorageValidations
1112
class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
1213
include ASVActiveStorageable
14+
include ASVAnalyzable
1315
include ASVAttachable
1416
include ASVErrorable
1517
include ASVOptionable

lib/active_storage_validations/dimension_validator.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative 'shared/asv_active_storageable'
4+
require_relative 'shared/asv_analyzable'
45
require_relative 'shared/asv_attachable'
56
require_relative 'shared/asv_errorable'
67
require_relative 'shared/asv_optionable'
@@ -9,6 +10,7 @@
910
module ActiveStorageValidations
1011
class DimensionValidator < ActiveModel::EachValidator # :nodoc
1112
include ASVActiveStorageable
13+
include ASVAnalyzable
1214
include ASVAttachable
1315
include ASVErrorable
1416
include ASVOptionable

0 commit comments

Comments
 (0)