active_storage_validations 1.3.5 → 2.0.0

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -279
  3. data/config/locales/da.yml +50 -29
  4. data/config/locales/de.yml +50 -29
  5. data/config/locales/en.yml +50 -29
  6. data/config/locales/es.yml +50 -29
  7. data/config/locales/fr.yml +50 -29
  8. data/config/locales/it.yml +50 -29
  9. data/config/locales/ja.yml +50 -29
  10. data/config/locales/nl.yml +50 -29
  11. data/config/locales/pl.yml +50 -29
  12. data/config/locales/pt-BR.yml +50 -29
  13. data/config/locales/ru.yml +50 -29
  14. data/config/locales/sv.yml +50 -29
  15. data/config/locales/tr.yml +50 -29
  16. data/config/locales/uk.yml +50 -29
  17. data/config/locales/vi.yml +50 -29
  18. data/config/locales/zh-CN.yml +50 -29
  19. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  20. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  21. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +12 -11
  22. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +12 -12
  23. data/lib/active_storage_validations/analyzer/image_analyzer.rb +18 -46
  24. data/lib/active_storage_validations/analyzer/null_analyzer.rb +2 -2
  25. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  26. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  27. data/lib/active_storage_validations/analyzer.rb +54 -1
  28. data/lib/active_storage_validations/aspect_ratio_validator.rb +154 -120
  29. data/lib/active_storage_validations/{base_size_validator.rb → base_comparison_validator.rb} +18 -16
  30. data/lib/active_storage_validations/content_type_validator.rb +51 -17
  31. data/lib/active_storage_validations/dimension_validator.rb +20 -19
  32. data/lib/active_storage_validations/duration_validator.rb +55 -0
  33. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +24 -0
  34. data/lib/active_storage_validations/{marcel_extensor.rb → extensors/asv_marcelable.rb} +5 -0
  35. data/lib/active_storage_validations/limit_validator.rb +14 -2
  36. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +1 -1
  37. data/lib/active_storage_validations/matchers/{base_size_validator_matcher.rb → base_comparison_validator_matcher.rb} +31 -25
  38. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +7 -3
  39. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +1 -1
  40. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  41. data/lib/active_storage_validations/matchers/{processable_image_validator_matcher.rb → processable_file_validator_matcher.rb} +5 -5
  42. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +18 -2
  43. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +18 -2
  44. data/lib/active_storage_validations/matchers.rb +4 -3
  45. data/lib/active_storage_validations/{processable_image_validator.rb → processable_file_validator.rb} +4 -3
  46. data/lib/active_storage_validations/railtie.rb +5 -0
  47. data/lib/active_storage_validations/shared/asv_active_storageable.rb +2 -2
  48. data/lib/active_storage_validations/shared/asv_analyzable.rb +38 -3
  49. data/lib/active_storage_validations/shared/asv_attachable.rb +36 -15
  50. data/lib/active_storage_validations/size_validator.rb +11 -3
  51. data/lib/active_storage_validations/total_size_validator.rb +9 -3
  52. data/lib/active_storage_validations/version.rb +1 -1
  53. data/lib/active_storage_validations.rb +7 -3
  54. metadata +14 -8
  55. data/lib/active_storage_validations/content_type_spoof_detector.rb +0 -96
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- # = Active Storage Image \Analyzer
4
+ # = ActiveStorageValidations Image \Analyzer
5
5
  #
6
6
  # This is an abstract base class for image analyzers, which extract width and height from an image attachable.
7
7
  #
@@ -9,68 +9,40 @@ module ActiveStorageValidations
9
9
  #
10
10
  # Example:
11
11
  #
12
- # ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
12
+ # ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
13
13
  # # => { width: 4104, height: 2736 }
14
14
  class Analyzer::ImageAnalyzer < Analyzer
15
+ @@supported_analyzers = {}
16
+
15
17
  def metadata
16
- read_image do |image|
17
- if rotated_image?(image)
18
- { width: image.height, height: image.width }
18
+ return {} unless analyzer_supported?
19
+
20
+ read_media do |media|
21
+ if rotated_image?(media)
22
+ { width: media.height, height: media.width }
19
23
  else
20
- { width: image.width, height: image.height }
24
+ { width: media.width, height: media.height }
21
25
  end
22
26
  end
23
27
  end
24
28
 
25
29
  private
26
30
 
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) }
31
+ def analyzer_supported?
32
+ if @@supported_analyzers.key?(self)
33
+ @@supported_analyzers.fetch(self)
55
34
  else
56
- IO.copy_stream(file_representation, tempfile)
57
- file_representation.rewind
35
+ @@supported_analyzers[self] = supported?
58
36
  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
37
  end
68
38
 
69
- def image_from_path(path)
39
+ # Override this method in a concrete subclass. Have it return true if the image is rotated.
40
+ def rotated_image?(media)
70
41
  raise NotImplementedError
71
42
  end
72
43
 
73
- def rotated_image?(image)
44
+ # Override this method in a concrete subclass. Have it return true if the analyzer is supported.
45
+ def supported?
74
46
  raise NotImplementedError
75
47
  end
76
48
  end
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- # = Active Storage Null Analyzer
4
+ # = ActiveStorageValidations Null Analyzer
5
5
  #
6
6
  # This is a fallback analyzer when the attachable media type is not supported
7
7
  # by our gem.
8
8
  #
9
9
  # Example:
10
10
  #
11
- # ActiveStorage::Analyzer::NullAnalyzer.new(attachable).metadata
11
+ # ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable).metadata
12
12
  # # => {}
13
13
  class Analyzer::NullAnalyzer < Analyzer
14
14
  def metadata
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations:::ASVFFProbable
5
+ #
6
+ # Validator helper methods for analyzers using FFprobe.
7
+ module ASVFFProbable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ def read_media
13
+ Tempfile.create(binmode: true) do |tempfile|
14
+ begin
15
+ if media(tempfile).present?
16
+ yield media(tempfile)
17
+ else
18
+ logger.info "Skipping file metadata analysis because ffprobe doesn't support the file"
19
+ {}
20
+ end
21
+ ensure
22
+ tempfile.close
23
+ end
24
+ end
25
+ rescue Errno::ENOENT
26
+ logger.info "Skipping file metadata analysis because ffprobe isn't installed"
27
+ {}
28
+ end
29
+
30
+ def media_from_path(path)
31
+ instrument(File.basename(ffprobe_path)) do
32
+ stdout, _stderr, status = Open3.capture3(
33
+ ffprobe_path,
34
+ "-print_format", "json",
35
+ "-show_streams",
36
+ "-show_format",
37
+ "-v", "error",
38
+ path
39
+ )
40
+
41
+ status.success? ? JSON.parse(stdout) : nil
42
+ end
43
+ end
44
+
45
+ def ffprobe_path
46
+ ActiveStorage.paths[:ffprobe] || "ffprobe"
47
+ end
48
+
49
+ def video_stream
50
+ @video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
51
+ end
52
+
53
+ def audio_stream
54
+ @audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {}
55
+ end
56
+
57
+ def streams
58
+ @streams ||= @media["streams"] || []
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative 'shared/asv_ff_probable'
5
+
6
+ module ActiveStorageValidations
7
+ # = ActiveStorageValidations Video \Analyzer
8
+ #
9
+ # Extracts the following from a video attachable:
10
+ #
11
+ # * Width (pixels)
12
+ # * Height (pixels)
13
+ # * Duration (seconds)
14
+ # * Angle (degrees)
15
+ # * Audio (true if file has an audio channel, false if not)
16
+ # * Video (true if file has an video channel, false if not)
17
+ #
18
+ # Example:
19
+ #
20
+ # ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable).metadata
21
+ # # => { width: 640, height: 480, duration: 5.0, angle: 0, audio: true, video: true }
22
+ #
23
+ # When a video's angle is 90, -90, 270 or -270 degrees, its width and height are automatically swapped for convenience.
24
+ #
25
+ # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
26
+ class Analyzer::VideoAnalyzer < Analyzer
27
+ include ASVFFProbable
28
+
29
+ def metadata
30
+ read_media do |media|
31
+ {
32
+ width: (Integer(width) if width),
33
+ height: (Integer(height) if height),
34
+ duration: duration,
35
+ angle: angle,
36
+ audio: audio?,
37
+ video: video?
38
+ }.compact
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def width
45
+ if rotated?
46
+ computed_height || encoded_height
47
+ else
48
+ encoded_width
49
+ end
50
+ end
51
+
52
+ def height
53
+ if rotated?
54
+ encoded_width
55
+ else
56
+ computed_height || encoded_height
57
+ end
58
+ end
59
+
60
+ def duration
61
+ duration = video_stream["duration"] || container["duration"]
62
+ Float(duration).round(1) if duration
63
+ end
64
+
65
+ def angle
66
+ if tags["rotate"]
67
+ Integer(tags["rotate"])
68
+ elsif display_matrix && display_matrix["rotation"]
69
+ Integer(display_matrix["rotation"])
70
+ end
71
+ end
72
+
73
+ def display_matrix
74
+ side_data.detect { |data| data["side_data_type"] == "Display Matrix" }
75
+ end
76
+
77
+ def display_aspect_ratio
78
+ if descriptor = video_stream["display_aspect_ratio"]
79
+ if terms = descriptor.split(":", 2)
80
+ numerator = Integer(terms[0])
81
+ denominator = Integer(terms[1])
82
+
83
+ [numerator, denominator] unless numerator == 0
84
+ end
85
+ end
86
+ end
87
+
88
+ def rotated?
89
+ angle == 90 || angle == 270 || angle == -90 || angle == -270
90
+ end
91
+
92
+ def audio?
93
+ audio_stream.present?
94
+ end
95
+
96
+ def video?
97
+ video_stream.present?
98
+ end
99
+
100
+ def computed_height
101
+ if encoded_width && display_height_scale
102
+ encoded_width * display_height_scale
103
+ end
104
+ end
105
+
106
+ def encoded_width
107
+ @encoded_width ||= Float(video_stream["width"]) if video_stream["width"]
108
+ end
109
+
110
+ def encoded_height
111
+ @encoded_height ||= Float(video_stream["height"]) if video_stream["height"]
112
+ end
113
+
114
+ def display_height_scale
115
+ @display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio
116
+ end
117
+
118
+ def tags
119
+ @tags ||= video_stream["tags"] || {}
120
+ end
121
+
122
+ def side_data
123
+ @side_data ||= video_stream["side_data_list"] || {}
124
+ end
125
+
126
+ def container
127
+ @container ||= @media["format"] || {}
128
+ end
129
+ end
130
+ end
@@ -7,7 +7,7 @@ module ActiveStorageValidations
7
7
  # = Active Storage Validations \Analyzer
8
8
  #
9
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.
10
+ # See ActiveStorageValidations::Analyzer::VideoAnalyzer for an example of a concrete subclass.
11
11
  #
12
12
  # Heavily (not to say 100%) inspired by Rails own ActiveStorage::Analyzer
13
13
  class Analyzer
@@ -20,6 +20,11 @@ module ActiveStorageValidations
20
20
  @attachable = attachable
21
21
  end
22
22
 
23
+ # Override this method in a concrete subclass. Have it return a String content type.
24
+ def content_type
25
+ raise NotImplementedError
26
+ end
27
+
23
28
  # Override this method in a concrete subclass. Have it return a Hash of metadata.
24
29
  def metadata
25
30
  raise NotImplementedError
@@ -27,6 +32,54 @@ module ActiveStorageValidations
27
32
 
28
33
  private
29
34
 
35
+ # Override this method in a concrete subclass. Have it yield a media object.
36
+ def read_media
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def media(tempfile)
41
+ @media ||= case @attachable
42
+ when ActiveStorage::Blob, String
43
+ blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
44
+ media_from_tempfile_path(tempfile, blob)
45
+ when Hash
46
+ io = @attachable[:io]
47
+ if io.is_a?(StringIO)
48
+ media_from_tempfile_path(tempfile, io)
49
+ else
50
+ File.open(io) do |file|
51
+ media_from_path(file.path)
52
+ end
53
+ end
54
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
55
+ media_from_path(@attachable.path)
56
+ when File
57
+ supports_file_attachment? ? media_from_path(@attachable.path) : raise_rails_like_error(@attachable)
58
+ when Pathname
59
+ supports_pathname_attachment? ? media_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
60
+ else
61
+ raise_rails_like_error(@attachable)
62
+ end
63
+ end
64
+
65
+ def media_from_tempfile_path(tempfile, file_representation)
66
+ if file_representation.is_a?(ActiveStorage::Blob)
67
+ file_representation.download { |chunk| tempfile.write(chunk) }
68
+ else
69
+ IO.copy_stream(file_representation, tempfile)
70
+ file_representation.rewind
71
+ end
72
+
73
+ tempfile.flush
74
+ tempfile.rewind
75
+ media_from_path(tempfile.path)
76
+ end
77
+
78
+ # Override this method in a concrete subclass. Have it return a media object.
79
+ def media_from_path(path)
80
+ raise NotImplementedError
81
+ end
82
+
30
83
  def instrument(analyzer, &block)
31
84
  ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
32
85
  end
@@ -1,120 +1,154 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'shared/asv_active_storageable'
4
- require_relative 'shared/asv_analyzable'
5
- require_relative 'shared/asv_attachable'
6
- require_relative 'shared/asv_errorable'
7
- require_relative 'shared/asv_optionable'
8
- require_relative 'shared/asv_symbolizable'
9
-
10
- module ActiveStorageValidations
11
- class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
12
- include ASVActiveStorageable
13
- include ASVAnalyzable
14
- include ASVAttachable
15
- include ASVErrorable
16
- include ASVOptionable
17
- include ASVSymbolizable
18
-
19
- AVAILABLE_CHECKS = %i[with].freeze
20
- NAMED_ASPECT_RATIOS = %i[square portrait landscape].freeze
21
- ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/.freeze
22
- ERROR_TYPES = %i[
23
- image_metadata_missing
24
- aspect_ratio_not_square
25
- aspect_ratio_not_portrait
26
- aspect_ratio_not_landscape
27
- aspect_ratio_is_not
28
- ].freeze
29
- PRECISION = 3.freeze
30
-
31
- def check_validity!
32
- ensure_at_least_one_validator_option
33
- ensure_aspect_ratio_validity
34
- end
35
-
36
- def validate_each(record, attribute, _value)
37
- return if no_attachments?(record, attribute)
38
-
39
- validate_changed_files_from_metadata(record, attribute)
40
- end
41
-
42
- private
43
-
44
- def is_valid?(record, attribute, attachable, metadata)
45
- flat_options = set_flat_options(record)
46
-
47
- return if image_metadata_missing?(record, attribute, attachable, flat_options, metadata)
48
-
49
- case flat_options[:with]
50
- when :square then validate_square_aspect_ratio(record, attribute, attachable, flat_options, metadata)
51
- when :portrait then validate_portrait_aspect_ratio(record, attribute, attachable, flat_options, metadata)
52
- when :landscape then validate_landscape_aspect_ratio(record, attribute, attachable, flat_options, metadata)
53
- when ASPECT_RATIO_REGEX then validate_regex_aspect_ratio(record, attribute, attachable, flat_options, metadata)
54
- end
55
- end
56
-
57
- def image_metadata_missing?(record, attribute, attachable, flat_options, metadata)
58
- return false if metadata.present? && metadata[:width].to_i > 0 && metadata[:height].to_i > 0
59
-
60
- errors_options = initialize_error_options(options, attachable)
61
- errors_options[:aspect_ratio] = flat_options[:with]
62
- add_error(record, attribute, :image_metadata_missing, **errors_options)
63
- true
64
- end
65
-
66
- def validate_square_aspect_ratio(record, attribute, attachable, flat_options, metadata)
67
- return if metadata[:width] == metadata[:height]
68
-
69
- errors_options = initialize_error_options(options, attachable)
70
- errors_options[:aspect_ratio] = flat_options[:with]
71
- add_error(record, attribute, :aspect_ratio_not_square, **errors_options)
72
- end
73
-
74
- def validate_portrait_aspect_ratio(record, attribute, attachable, flat_options, metadata)
75
- return if metadata[:width] < metadata[:height]
76
-
77
- errors_options = initialize_error_options(options, attachable)
78
- errors_options[:aspect_ratio] = flat_options[:with]
79
- add_error(record, attribute, :aspect_ratio_not_portrait, **errors_options)
80
- end
81
-
82
- def validate_landscape_aspect_ratio(record, attribute, attachable, flat_options, metadata)
83
- return if metadata[:width] > metadata[:height]
84
-
85
- errors_options = initialize_error_options(options, attachable)
86
- errors_options[:aspect_ratio] = flat_options[:with]
87
- add_error(record, attribute, :aspect_ratio_not_landscape, **errors_options)
88
- end
89
-
90
- def validate_regex_aspect_ratio(record, attribute, attachable, flat_options, metadata)
91
- flat_options[:with] =~ ASPECT_RATIO_REGEX
92
- x = $1.to_i
93
- y = $2.to_i
94
-
95
- return if x > 0 && y > 0 && (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
96
-
97
- errors_options = initialize_error_options(options, attachable)
98
- errors_options[:aspect_ratio] = "#{x}:#{y}"
99
- add_error(record, attribute, :aspect_ratio_is_not, **errors_options)
100
- end
101
-
102
- def ensure_at_least_one_validator_option
103
- unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
104
- raise ArgumentError, 'You must pass :with to the validator'
105
- end
106
- end
107
-
108
- def ensure_aspect_ratio_validity
109
- return true if options[:with]&.is_a?(Proc)
110
-
111
- unless NAMED_ASPECT_RATIOS.include?(options[:with]) || options[:with] =~ ASPECT_RATIO_REGEX
112
- raise ArgumentError, <<~ERROR_MESSAGE
113
- You must pass a valid aspect ratio to the validator
114
- It should either be a named aspect ratio (#{NAMED_ASPECT_RATIOS.join(', ')})
115
- Or an aspect ratio like 'is_16_9' (matching /#{ASPECT_RATIO_REGEX.source}/)
116
- ERROR_MESSAGE
117
- end
118
- end
119
- end
120
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
5
+ require_relative 'shared/asv_attachable'
6
+ require_relative 'shared/asv_errorable'
7
+ require_relative 'shared/asv_optionable'
8
+ require_relative 'shared/asv_symbolizable'
9
+
10
+ module ActiveStorageValidations
11
+ class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
12
+ include ASVActiveStorageable
13
+ include ASVAnalyzable
14
+ include ASVAttachable
15
+ include ASVErrorable
16
+ include ASVOptionable
17
+ include ASVSymbolizable
18
+
19
+ AVAILABLE_CHECKS = %i[with in].freeze
20
+ NAMED_ASPECT_RATIOS = %i[square portrait landscape].freeze
21
+ ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/.freeze
22
+ ERROR_TYPES = %i[
23
+ aspect_ratio_not_square
24
+ aspect_ratio_not_portrait
25
+ aspect_ratio_not_landscape
26
+ aspect_ratio_not_x_y
27
+ aspect_ratio_invalid
28
+ media_metadata_missing
29
+ ].freeze
30
+ PRECISION = 3.freeze
31
+ METADATA_KEYS = %i[width height].freeze
32
+
33
+ def check_validity!
34
+ ensure_at_least_one_validator_option
35
+ ensure_aspect_ratio_validity
36
+ end
37
+
38
+ def validate_each(record, attribute, _value)
39
+ return if no_attachments?(record, attribute)
40
+
41
+ flat_options = set_flat_options(record)
42
+ @authorized_aspect_ratios = authorized_aspect_ratios_from_options(flat_options).compact
43
+ return if @authorized_aspect_ratios.empty?
44
+
45
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
46
+ end
47
+
48
+ private
49
+
50
+ def is_valid?(record, attribute, attachable, metadata)
51
+ !media_metadata_missing?(record, attribute, attachable, metadata) &&
52
+ authorized_aspect_ratio?(record, attribute, attachable, metadata)
53
+ end
54
+
55
+ def authorized_aspect_ratio?(record, attribute, attachable, metadata)
56
+ attachable_aspect_ratio_is_authorized = @authorized_aspect_ratios.any? do |authorized_aspect_ratio|
57
+ case authorized_aspect_ratio
58
+ when :square then valid_square_aspect_ratio?(metadata)
59
+ when :portrait then valid_portrait_aspect_ratio?(metadata)
60
+ when :landscape then valid_landscape_aspect_ratio?(metadata)
61
+ when ASPECT_RATIO_REGEX then valid_regex_aspect_ratio?(authorized_aspect_ratio, metadata)
62
+ end
63
+ end
64
+
65
+ return true if attachable_aspect_ratio_is_authorized
66
+
67
+ errors_options = initialize_error_options(options, attachable)
68
+ error_type = aspect_ratio_error_mapping
69
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
70
+ errors_options[:width] = metadata[:width]
71
+ errors_options[:height] = metadata[:height]
72
+ add_error(record, attribute, error_type, **errors_options)
73
+ false
74
+ end
75
+
76
+ def aspect_ratio_error_mapping
77
+ return :aspect_ratio_invalid if @authorized_aspect_ratios.many?
78
+
79
+ aspect_ratio = @authorized_aspect_ratios.first
80
+ NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
81
+ end
82
+
83
+ def media_metadata_missing?(record, attribute, attachable, metadata)
84
+ return false if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
85
+
86
+ errors_options = initialize_error_options(options, attachable)
87
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
88
+ add_error(record, attribute, :media_metadata_missing, **errors_options)
89
+ true
90
+ end
91
+
92
+ def valid_square_aspect_ratio?(metadata)
93
+ metadata[:width] == metadata[:height]
94
+ end
95
+
96
+ def valid_portrait_aspect_ratio?(metadata)
97
+ metadata[:width] < metadata[:height]
98
+ end
99
+
100
+ def valid_landscape_aspect_ratio?(metadata)
101
+ metadata[:width] > metadata[:height]
102
+ end
103
+
104
+ def valid_regex_aspect_ratio?(aspect_ratio, metadata)
105
+ aspect_ratio =~ ASPECT_RATIO_REGEX
106
+ x = ::Regexp.last_match(1).to_i
107
+ y = ::Regexp.last_match(2).to_i
108
+
109
+ x > 0 && y > 0 && (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
110
+ end
111
+
112
+ def ensure_at_least_one_validator_option
113
+ return if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
114
+
115
+ raise ArgumentError, 'You must pass either :with or :in to the validator'
116
+ end
117
+
118
+ def ensure_aspect_ratio_validity
119
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
120
+
121
+ authorized_aspect_ratios_from_options(options).each do |aspect_ratio|
122
+ unless NAMED_ASPECT_RATIOS.include?(aspect_ratio) || aspect_ratio =~ ASPECT_RATIO_REGEX
123
+ raise ArgumentError, invalid_aspect_ratio_message
124
+ end
125
+ end
126
+ end
127
+
128
+ def invalid_aspect_ratio_message
129
+ <<~ERROR_MESSAGE
130
+ You must pass a valid aspect ratio to the validator
131
+ It should either be a named aspect ratio (#{NAMED_ASPECT_RATIOS.join(', ')})
132
+ Or an aspect ratio like 'is_16_9' (matching /#{ASPECT_RATIO_REGEX.source}/)
133
+ ERROR_MESSAGE
134
+ end
135
+
136
+ def authorized_aspect_ratios_from_options(flat_options)
137
+ (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in]))
138
+ end
139
+
140
+ def string_aspect_ratios
141
+ @authorized_aspect_ratios.map do |aspect_ratio|
142
+ if NAMED_ASPECT_RATIOS.include?(aspect_ratio)
143
+ aspect_ratio
144
+ else
145
+ aspect_ratio =~ ASPECT_RATIO_REGEX
146
+ x = ::Regexp.last_match(1).to_i
147
+ y = ::Regexp.last_match(2).to_i
148
+
149
+ "#{x}:#{y}"
150
+ end
151
+ end.join(', ')
152
+ end
153
+ end
154
+ end