active_storage_validations 1.3.5 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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