active_storage_validations 1.3.4 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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