active_storage_validations 1.3.4 → 1.3.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0539919d5597eda332d4c989c432986009f1944e0ab62251882cc250fa6f021a'
4
- data.tar.gz: 8d7566007ede3adf24a60297c20d7a39b28e211bc00f5158148f3c2cdf0a82e5
3
+ metadata.gz: 1fe705d1a3b76d875ddc0a61d7d3caa439ddad3ba4a8b5be3c203f8d8cdd00ca
4
+ data.tar.gz: 826dbac96af4e1ed424ad16670aea3e428296ab07ae9bfdad29e4c0b453d114d
5
5
  SHA512:
6
- metadata.gz: 364050c102e1fa3feac404972479edf30eb25d7df969e7593afae838ffe556bb0cb69693c94ad8ced10b056b0d8b5c232800bc3885bdc37984c9cf51a836bff1
7
- data.tar.gz: 7350e9ffb7d0c93bc233561ab3457aec477f988c447708498d791f559440a08207cfddf245b2b835716f978d2d8171f4c71252697183df5284ee8f33ce136540
6
+ metadata.gz: 4ccb7a673c9c5a7d6508e49c6b4b92b50b08faa9395a6e6c7da3bef460a6b72b64900d2ce7e529c5f18224c5b96e23bf82e8142f5d6dc047c327216b117bff21
7
+ data.tar.gz: f29bcdf4a6142b99d1cabf53086b682925256afc433e106a52feca3e96f097203c5e27ed7419ea51fc9b4ee6a2c454b06b0cdbb56a1d3f890fa6f452b529be4f
data/README.md CHANGED
@@ -354,8 +354,8 @@ describe User do
354
354
 
355
355
  # limit
356
356
  # #min, #max
357
- it { is_expected.to validate_limit_of(:avatar).min(1) }
358
- it { is_expected.to validate_limit_of(:avatar).max(5) }
357
+ it { is_expected.to validate_limits_of(:avatar).min(1) }
358
+ it { is_expected.to validate_limits_of(:avatar).max(5) }
359
359
 
360
360
  # content_type:
361
361
  # #allowing, #rejecting
@@ -0,0 +1,46 @@
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" unless defined?(MiniMagick)
14
+ rescue LoadError
15
+ logger.info "Skipping image analysis because the mini_magick gem isn't installed"
16
+ return {}
17
+ end
18
+
19
+ Tempfile.create(binmode: true) do |tempfile|
20
+ begin
21
+ if image(tempfile).valid?
22
+ yield image(tempfile)
23
+ else
24
+ logger.info "Skipping image analysis because ImageMagick doesn't support the file"
25
+ {}
26
+ end
27
+ ensure
28
+ tempfile.close
29
+ end
30
+ end
31
+ rescue MiniMagick::Error => error
32
+ logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
33
+ {}
34
+ end
35
+
36
+ def image_from_path(path)
37
+ instrument("mini_magick") do
38
+ MiniMagick::Image.new(path)
39
+ end
40
+ end
41
+
42
+ def rotated_image?(image)
43
+ %w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,57 @@
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
+
8
+ private
9
+
10
+ def read_image
11
+ begin
12
+ require "vips" unless defined?(::Vips)
13
+ rescue LoadError
14
+ logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
15
+ return {}
16
+ end
17
+
18
+ Tempfile.create(binmode: true) do |tempfile|
19
+ begin
20
+ if image(tempfile)
21
+ yield image(tempfile)
22
+ else
23
+ logger.info "Skipping image analysis because Vips doesn't support the file"
24
+ {}
25
+ end
26
+ ensure
27
+ tempfile.close
28
+ end
29
+ end
30
+ rescue ::Vips::Error => error
31
+ logger.error "Skipping image analysis due to a Vips error: #{error.message}"
32
+ {}
33
+ end
34
+
35
+ def image_from_path(path)
36
+ instrument("vips") do
37
+ begin
38
+ ::Vips::Image.new_from_file(path, access: :sequential)
39
+ rescue ::Vips::Error
40
+ # Vips throw errors rather than returning false when reading a not
41
+ # supported attachable.
42
+ # We stumbled upon this issue while reading 0 byte size attachable
43
+ # https://github.com/janko/image_processing/issues/97
44
+ logger.info "Skipping image analysis because Vips doesn't support the file"
45
+ nil
46
+ end
47
+ end
48
+ end
49
+
50
+ ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
51
+ def rotated_image?(image)
52
+ ROTATIONS === image.get("exif-ifd0-Orientation")
53
+ rescue ::Vips::Error
54
+ false
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,77 @@
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 metadata
16
+ read_image do |image|
17
+ if rotated_image?(image)
18
+ { width: image.height, height: image.width }
19
+ else
20
+ { width: image.width, height: image.height }
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def image(tempfile)
28
+ case @attachable
29
+ when ActiveStorage::Blob, String
30
+ blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
31
+ image_from_tempfile_path(tempfile, blob)
32
+ when Hash
33
+ io = @attachable[:io]
34
+ if io.is_a?(StringIO)
35
+ image_from_tempfile_path(tempfile, io)
36
+ else
37
+ File.open(io) do |file|
38
+ image_from_path(file.path)
39
+ end
40
+ end
41
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
42
+ image_from_path(@attachable.path)
43
+ when File
44
+ supports_file_attachment? ? image_from_path(@attachable.path) : raise_rails_like_error(@attachable)
45
+ when Pathname
46
+ supports_pathname_attachment? ? image_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
47
+ else
48
+ raise_rails_like_error(@attachable)
49
+ end
50
+ end
51
+
52
+ def image_from_tempfile_path(tempfile, file_representation)
53
+ if file_representation.is_a?(ActiveStorage::Blob)
54
+ file_representation.download { |chunk| tempfile.write(chunk) }
55
+ else
56
+ IO.copy_stream(file_representation, tempfile)
57
+ file_representation.rewind
58
+ end
59
+
60
+ tempfile.flush
61
+ tempfile.rewind
62
+ image_from_path(tempfile.path)
63
+ end
64
+
65
+ def read_image
66
+ raise NotImplementedError
67
+ end
68
+
69
+ def image_from_path(path)
70
+ raise NotImplementedError
71
+ end
72
+
73
+ def rotated_image?(image)
74
+ raise NotImplementedError
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,18 @@
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 metadata
15
+ {}
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
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
+ def initialize(attachable)
20
+ @attachable = attachable
21
+ end
22
+
23
+ # Override this method in a concrete subclass. Have it return a Hash of metadata.
24
+ def metadata
25
+ raise NotImplementedError
26
+ end
27
+
28
+ private
29
+
30
+ def instrument(analyzer, &block)
31
+ ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
32
+ end
33
+ end
34
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
4
5
  require_relative 'shared/asv_attachable'
5
6
  require_relative 'shared/asv_errorable'
6
7
  require_relative 'shared/asv_optionable'
@@ -9,6 +10,7 @@ require_relative 'shared/asv_symbolizable'
9
10
  module ActiveStorageValidations
10
11
  class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
11
12
  include ASVActiveStorageable
13
+ include ASVAnalyzable
12
14
  include ASVAttachable
13
15
  include ASVErrorable
14
16
  include ASVOptionable
@@ -53,7 +55,7 @@ module ActiveStorageValidations
53
55
  end
54
56
 
55
57
  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
57
59
 
58
60
  errors_options = initialize_error_options(options, attachable)
59
61
  errors_options[:aspect_ratio] = flat_options[:with]
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'shared/asv_analyzable'
3
4
  require_relative 'shared/asv_attachable'
4
5
  require_relative 'shared/asv_loggable'
5
6
  require 'open3'
@@ -8,6 +9,7 @@ module ActiveStorageValidations
8
9
  class ContentTypeSpoofDetector
9
10
  class FileCommandLineToolNotInstalledError < StandardError; end
10
11
 
12
+ include ASVAnalyzable
11
13
  include ASVAttachable
12
14
  include ASVLoggable
13
15
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
4
5
  require_relative 'shared/asv_attachable'
5
6
  require_relative 'shared/asv_errorable'
6
7
  require_relative 'shared/asv_optionable'
@@ -10,6 +11,7 @@ require_relative 'content_type_spoof_detector'
10
11
  module ActiveStorageValidations
11
12
  class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
12
13
  include ASVActiveStorageable
14
+ include ASVAnalyzable
13
15
  include ASVAttachable
14
16
  include ASVErrorable
15
17
  include ASVOptionable
@@ -52,7 +54,7 @@ module ActiveStorageValidations
52
54
  end
53
55
 
54
56
  def set_attachable_cached_values(attachable)
55
- @attachable_content_type = attachable_content_type(attachable)
57
+ @attachable_content_type = attachable_content_type_rails_like(attachable)
56
58
  @attachable_filename = attachable_filename(attachable).to_s
57
59
  end
58
60
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
4
5
  require_relative 'shared/asv_attachable'
5
6
  require_relative 'shared/asv_errorable'
6
7
  require_relative 'shared/asv_optionable'
@@ -9,6 +10,7 @@ require_relative 'shared/asv_symbolizable'
9
10
  module ActiveStorageValidations
10
11
  class DimensionValidator < ActiveModel::EachValidator # :nodoc
11
12
  include ASVActiveStorageable
13
+ include ASVAnalyzable
12
14
  include ASVAttachable
13
15
  include ASVErrorable
14
16
  include ASVOptionable
@@ -43,10 +43,10 @@ module ActiveStorageValidations
43
43
  @subject = subject.is_a?(Class) ? subject.new : subject
44
44
 
45
45
  is_a_valid_active_storage_attribute? &&
46
- is_context_valid? &&
47
- is_custom_message_valid? &&
48
- is_valid_when_image_processable? &&
49
- is_invalid_when_image_not_processable?
46
+ is_context_valid? &&
47
+ is_custom_message_valid? &&
48
+ is_valid_when_image_processable? &&
49
+ is_invalid_when_image_not_processable?
50
50
  end
51
51
 
52
52
  private
@@ -27,7 +27,8 @@ module ActiveStorageValidations
27
27
 
28
28
  def self.mock_metadata(attachment, width, height)
29
29
  mock = Struct.new(:metadata).new({ width: width, height: height })
30
- stub_method(ActiveStorageValidations::Metadata, :new, mock) do
30
+
31
+ stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
31
32
  yield
32
33
  end
33
34
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
4
5
  require_relative 'shared/asv_attachable'
5
6
  require_relative 'shared/asv_errorable'
6
7
  require_relative 'shared/asv_symbolizable'
@@ -8,6 +9,7 @@ require_relative 'shared/asv_symbolizable'
8
9
  module ActiveStorageValidations
9
10
  class ProcessableImageValidator < ActiveModel::EachValidator # :nodoc
10
11
  include ASVActiveStorageable
12
+ include ASVAnalyzable
11
13
  include ASVAttachable
12
14
  include ASVErrorable
13
15
  include ASVSymbolizable
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::ASVAnalyzable
5
+ #
6
+ # Validator methods for choosing the right analyzer depending on the file
7
+ # media type and available third-party analyzers.
8
+ module ASVAnalyzable
9
+ extend ActiveSupport::Concern
10
+
11
+ DEFAULT_IMAGE_PROCESSOR = :mini_magick.freeze
12
+
13
+ private
14
+
15
+ def metadata_for(attachable)
16
+ analyzer_for(attachable).metadata
17
+ end
18
+
19
+ def analyzer_for(attachable)
20
+ case attachable_media_type(attachable)
21
+ when "image" then image_analyzer_for(attachable)
22
+ else fallback_analyzer_for(attachable)
23
+ end
24
+ end
25
+
26
+ def image_analyzer_for(attachable)
27
+ case image_processor
28
+ when :mini_magick
29
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
30
+ when :vips
31
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
32
+ end
33
+ end
34
+
35
+ def image_processor
36
+ # Rails returns nil for default image processor, because it is set in an after initialize callback
37
+ # https://github.com/rails/rails/blob/main/activestorage/lib/active_storage/engine.rb
38
+ ActiveStorage.variant_processor || DEFAULT_IMAGE_PROCESSOR
39
+ end
40
+
41
+ def fallback_analyzer_for(attachable)
42
+ ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
43
+ end
44
+ end
45
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../metadata"
4
-
5
3
  module ActiveStorageValidations
6
4
  # ActiveStorageValidations::ASVAttachable
7
5
  #
@@ -20,7 +18,7 @@ module ActiveStorageValidations
20
18
  # to perform file analyses.
21
19
  def validate_changed_files_from_metadata(record, attribute)
22
20
  attachables_from_changes(record, attribute).each do |attachable|
23
- is_valid?(record, attribute, attachable, Metadata.new(attachable).metadata)
21
+ is_valid?(record, attribute, attachable, metadata_for(attachable))
24
22
  end
25
23
  end
26
24
 
@@ -64,25 +62,67 @@ module ActiveStorageValidations
64
62
  def attachable_content_type(attachable)
65
63
  full_attachable_content_type(attachable) && full_attachable_content_type(attachable).downcase.split(/[;,\s]/, 2).first
66
64
  end
65
+
66
+ # Retrieve the content_type from attachable using the same logic as Rails
67
+ # ActiveStorage::Blob::Identifiable#identify_content_type
68
+ def attachable_content_type_rails_like(attachable)
69
+ Marcel::MimeType.for(
70
+ attachable_io(attachable, max_byte_size: 4.kilobytes),
71
+ name: attachable_filename(attachable).to_s,
72
+ declared_type: full_attachable_content_type(attachable)
73
+ )
74
+ end
67
75
 
76
+ # Retrieve the media type of the attachable, which is the first part of the
77
+ # content type (or mime type).
78
+ # Possible values are: application/audio/example/font/image/model/text/video
79
+ def attachable_media_type(attachable)
80
+ (full_attachable_content_type(attachable) || marcel_content_type_from_filename(attachable)).split("/").first
81
+ end
82
+
68
83
  # Retrieve the io from attachable.
69
- def attachable_io(attachable)
84
+ def attachable_io(attachable, max_byte_size: nil)
85
+ io = case attachable
86
+ when ActiveStorage::Blob
87
+ (max_byte_size && supports_blob_download_chunk?) ? attachable.download_chunk(0...max_byte_size) : attachable.download
88
+ when ActionDispatch::Http::UploadedFile
89
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
90
+ when Rack::Test::UploadedFile
91
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
92
+ when String
93
+ blob = ActiveStorage::Blob.find_signed!(attachable)
94
+ (max_byte_size && supports_blob_download_chunk?) ? blob.download_chunk(0...max_byte_size) : blob.download
95
+ when Hash
96
+ max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
97
+ when File
98
+ raise_rails_like_error(attachable) unless supports_file_attachment?
99
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
100
+ when Pathname
101
+ raise_rails_like_error(attachable) unless supports_pathname_attachment?
102
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
103
+ else
104
+ raise_rails_like_error(attachable)
105
+ end
106
+
107
+ rewind_attachable_io(attachable)
108
+ io
109
+ end
110
+
111
+ # Rewind the io attachable.
112
+ def rewind_attachable_io(attachable)
70
113
  case attachable
71
- when ActiveStorage::Blob
72
- attachable.download
73
- when ActionDispatch::Http::UploadedFile
74
- attachable.read
75
- when Rack::Test::UploadedFile
76
- attachable.read
77
- when String
78
- blob = ActiveStorage::Blob.find_signed!(attachable)
79
- blob.download
114
+ when ActiveStorage::Blob, String
115
+ # nothing to do
116
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
117
+ attachable.rewind
80
118
  when Hash
81
- attachable[:io].read
119
+ attachable[:io].rewind
82
120
  when File
83
- supports_file_attachment? ? attachable : raise_rails_like_error(attachable)
121
+ raise_rails_like_error(attachable) unless supports_file_attachment?
122
+ attachable.rewind
84
123
  when Pathname
85
- supports_pathname_attachment? ? attachable.read : raise_rails_like_error(attachable)
124
+ raise_rails_like_error(attachable) unless supports_pathname_attachment?
125
+ File.open(attachable) { |f| f.rewind }
86
126
  else
87
127
  raise_rails_like_error(attachable)
88
128
  end
@@ -128,6 +168,13 @@ module ActiveStorageValidations
128
168
  end
129
169
  alias :supports_pathname_attachment? :supports_file_attachment?
130
170
 
171
+ # Check if the current Rails version supports ActiveStorage::Blob#download_chunk
172
+ #
173
+ # https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
174
+ def supports_blob_download_chunk?
175
+ Rails.gem_version >= Gem::Version.new('7.0.0.alpha1')
176
+ end
177
+
131
178
  # Retrieve the content_type from the file name only
132
179
  def marcel_content_type_from_filename(attachable)
133
180
  Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- VERSION = '1.3.4'
4
+ VERSION = '1.3.5'
5
5
  end
@@ -3,6 +3,12 @@
3
3
  require 'active_model'
4
4
  require 'active_support/concern'
5
5
 
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
+
6
12
  require 'active_storage_validations/railtie'
7
13
  require 'active_storage_validations/engine'
8
14
  require 'active_storage_validations/attached_validator'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage_validations
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.4
4
+ version: 1.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Kasyanchuk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-14 00:00:00.000000000 Z
11
+ date: 2024-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -259,6 +259,11 @@ files:
259
259
  - config/locales/vi.yml
260
260
  - config/locales/zh-CN.yml
261
261
  - lib/active_storage_validations.rb
262
+ - lib/active_storage_validations/analyzer.rb
263
+ - lib/active_storage_validations/analyzer/image_analyzer.rb
264
+ - lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb
265
+ - lib/active_storage_validations/analyzer/image_analyzer/vips.rb
266
+ - lib/active_storage_validations/analyzer/null_analyzer.rb
262
267
  - lib/active_storage_validations/aspect_ratio_validator.rb
263
268
  - lib/active_storage_validations/attached_validator.rb
264
269
  - lib/active_storage_validations/base_size_validator.rb
@@ -285,10 +290,10 @@ files:
285
290
  - lib/active_storage_validations/matchers/shared/asv_validatable.rb
286
291
  - lib/active_storage_validations/matchers/size_validator_matcher.rb
287
292
  - lib/active_storage_validations/matchers/total_size_validator_matcher.rb
288
- - lib/active_storage_validations/metadata.rb
289
293
  - lib/active_storage_validations/processable_image_validator.rb
290
294
  - lib/active_storage_validations/railtie.rb
291
295
  - lib/active_storage_validations/shared/asv_active_storageable.rb
296
+ - lib/active_storage_validations/shared/asv_analyzable.rb
292
297
  - lib/active_storage_validations/shared/asv_attachable.rb
293
298
  - lib/active_storage_validations/shared/asv_errorable.rb
294
299
  - lib/active_storage_validations/shared/asv_loggable.rb
@@ -1,179 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'shared/asv_loggable'
4
-
5
- module ActiveStorageValidations
6
- class Metadata
7
- include ASVLoggable
8
-
9
- class InvalidImageError < StandardError; end
10
-
11
- attr_reader :attachable
12
-
13
- DEFAULT_IMAGE_PROCESSOR = :mini_magick.freeze
14
-
15
- def initialize(attachable)
16
- require_image_processor
17
- @attachable = attachable
18
- end
19
-
20
- def valid?
21
- read_image
22
- true
23
- rescue InvalidImageError
24
- false
25
- end
26
-
27
- def metadata
28
- read_image do |image|
29
- if rotated_image?(image)
30
- { width: image.height, height: image.width }
31
- else
32
- { width: image.width, height: image.height }
33
- end
34
- end
35
- rescue InvalidImageError
36
- logger.info "Skipping image analysis because ImageMagick or Vips doesn't support the file"
37
- {}
38
- end
39
-
40
- private
41
-
42
- def image_processor
43
- # Rails returns nil for default image processor, because it is set in an after initialize callback
44
- # https://github.com/rails/rails/blob/89d8569abe2564c8187debf32dd3b4e33d6ad983/activestorage/lib/active_storage/engine.rb
45
- Rails.application.config.active_storage.variant_processor || DEFAULT_IMAGE_PROCESSOR
46
- end
47
-
48
- def require_image_processor
49
- case image_processor
50
- when :vips then require 'vips' unless defined?(Vips)
51
- when :mini_magick then require 'mini_magick' unless defined?(MiniMagick)
52
- end
53
- end
54
-
55
- def exception_class
56
- case image_processor
57
- when :vips then Vips::Error
58
- when :mini_magick then MiniMagick::Error
59
- end
60
- end
61
-
62
- def vips_image_processor?
63
- image_processor == :vips
64
- end
65
-
66
- def read_image
67
- is_string = attachable.is_a?(String)
68
- if is_string || attachable.is_a?(ActiveStorage::Blob)
69
- blob = is_string ? ActiveStorage::Blob.find_signed!(attachable) : attachable
70
-
71
- tempfile = Tempfile.new(["ActiveStorage-#{blob.id}-", blob.filename.extension_with_delimiter])
72
- tempfile.binmode
73
-
74
- blob.download do |chunk|
75
- tempfile.write(chunk)
76
- end
77
-
78
- tempfile.flush
79
- tempfile.rewind
80
-
81
- image = new_image_from_path(tempfile.path)
82
- else
83
- file_path = read_file_path
84
- image = new_image_from_path(file_path)
85
- end
86
-
87
-
88
- raise InvalidImageError unless valid_image?(image)
89
- yield image if block_given?
90
- rescue LoadError, NameError
91
- logger.info "Skipping image analysis because the mini_magick or ruby-vips gem isn't installed"
92
- {}
93
- rescue exception_class => error
94
- logger.error "Skipping image analysis due to an #{exception_class.name.split('::').map(&:downcase).join(' ').capitalize} error: #{error.message}"
95
- {}
96
- ensure
97
- image = nil
98
- end
99
-
100
- def new_image_from_path(path)
101
- if vips_image_processor? && (supported_vips_suffix?(path) || vips_version_below_8_8? || open_uri_tempfile?(path))
102
- begin
103
- Vips::Image.new_from_file(path)
104
- rescue exception_class
105
- # We handle cases where an error is raised when reading the attachable
106
- # because Vips can throw errors rather than returning false
107
- # We stumble upon this issue while reading 0 byte size attachable
108
- # https://github.com/janko/image_processing/issues/97
109
- false
110
- end
111
- elsif defined?(MiniMagick)
112
- MiniMagick::Image.new(path)
113
- end
114
- end
115
-
116
- def supported_vips_suffix?(path)
117
- Vips::get_suffixes.include?(File.extname(path).downcase)
118
- end
119
-
120
- def vips_version_below_8_8?
121
- # FYI, Vips 8.8 was released in 2019
122
- # https://github.com/libvips/libvips/releases/tag/v8.8.0
123
- !Vips::respond_to?(:vips_foreign_get_suffixes)
124
- end
125
-
126
- def open_uri_tempfile?(path)
127
- # When trying to open urls for 'large' images, OpenURI will return a
128
- # tempfile. That tempfile does not have an extension indicating the type
129
- # of file. However, Vips will be able to process it anyway.
130
- # The 'large' file value is derived from OpenUri::Buffer class (> 10ko)
131
- path.split('/').last.starts_with?("open-uri")
132
- end
133
-
134
- def valid_image?(image)
135
- return false unless image
136
-
137
- vips_image_processor? && image.is_a?(Vips::Image) ? image.avg : image.valid?
138
- rescue exception_class
139
- false
140
- end
141
-
142
- def rotated_image?(image)
143
- if vips_image_processor? && image.is_a?(Vips::Image)
144
- image.get('exif-ifd0-Orientation').include?('Right-top') ||
145
- image.get('exif-ifd0-Orientation').include?('Left-bottom')
146
- else
147
- %w[ RightTop LeftBottom ].include?(image["%[orientation]"])
148
- end
149
- rescue exception_class # field "exif-ifd0-Orientation" not found
150
- false
151
- end
152
-
153
- def read_file_path
154
- case attachable
155
- when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
156
- attachable.path
157
- when Hash
158
- io = attachable.fetch(:io)
159
- if io.is_a?(StringIO)
160
- tempfile = Tempfile.new([File.basename(attachable[:filename], '.*'), File.extname(attachable[:filename])])
161
- tempfile.binmode
162
- IO.copy_stream(io, tempfile)
163
- io.rewind
164
- tempfile.flush
165
- tempfile.rewind
166
- tempfile.path
167
- else
168
- File.open(io).path
169
- end
170
- when File
171
- attachable.path
172
- when Pathname
173
- attachable.to_s
174
- else
175
- raise "Something wrong with params."
176
- end
177
- end
178
- end
179
- end