active_storage_validations 1.0.4 → 3.0.2

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +785 -245
  3. data/config/locales/da.yml +63 -0
  4. data/config/locales/de.yml +60 -19
  5. data/config/locales/en-GB.yml +63 -0
  6. data/config/locales/en.yml +60 -20
  7. data/config/locales/es.yml +60 -19
  8. data/config/locales/fr.yml +60 -19
  9. data/config/locales/it.yml +60 -19
  10. data/config/locales/ja.yml +60 -19
  11. data/config/locales/nl.yml +60 -19
  12. data/config/locales/pl.yml +60 -19
  13. data/config/locales/pt-BR.yml +60 -19
  14. data/config/locales/ru.yml +60 -19
  15. data/config/locales/sv.yml +63 -0
  16. data/config/locales/tr.yml +60 -19
  17. data/config/locales/uk.yml +60 -19
  18. data/config/locales/vi.yml +60 -19
  19. data/config/locales/zh-CN.yml +60 -19
  20. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  21. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  22. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +46 -0
  23. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +56 -0
  24. data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
  25. data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
  26. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +89 -0
  27. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  28. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  29. data/lib/active_storage_validations/analyzer.rb +88 -0
  30. data/lib/active_storage_validations/aspect_ratio_validator.rb +157 -97
  31. data/lib/active_storage_validations/attached_validator.rb +22 -5
  32. data/lib/active_storage_validations/base_comparison_validator.rb +83 -0
  33. data/lib/active_storage_validations/content_type_validator.rb +219 -31
  34. data/lib/active_storage_validations/dimension_validator.rb +187 -97
  35. data/lib/active_storage_validations/duration_validator.rb +70 -0
  36. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +56 -0
  37. data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
  38. data/lib/active_storage_validations/limit_validator.rb +76 -9
  39. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
  40. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
  41. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +150 -0
  42. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +98 -39
  43. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +93 -55
  44. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  45. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  46. data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +39 -0
  47. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
  48. data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
  49. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
  50. data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
  51. data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +57 -0
  52. data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
  53. data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
  54. data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
  55. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
  56. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
  57. data/lib/active_storage_validations/matchers.rb +17 -21
  58. data/lib/active_storage_validations/pages_validator.rb +61 -0
  59. data/lib/active_storage_validations/processable_file_validator.rb +37 -0
  60. data/lib/active_storage_validations/railtie.rb +14 -0
  61. data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
  62. data/lib/active_storage_validations/shared/asv_analyzable.rb +89 -0
  63. data/lib/active_storage_validations/shared/asv_attachable.rb +236 -0
  64. data/lib/active_storage_validations/shared/asv_errorable.rb +64 -0
  65. data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
  66. data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
  67. data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
  68. data/lib/active_storage_validations/size_validator.rb +24 -41
  69. data/lib/active_storage_validations/total_size_validator.rb +52 -0
  70. data/lib/active_storage_validations/version.rb +1 -1
  71. data/lib/active_storage_validations.rb +27 -13
  72. metadata +113 -31
  73. data/lib/active_storage_validations/metadata.rb +0 -151
  74. data/lib/active_storage_validations/option_proc_unfolding.rb +0 -16
  75. data/lib/active_storage_validations/processable_image_validator.rb +0 -43
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # = ActiveStorageValidations 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
+ # ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
13
+ # # => { width: 4104, height: 2736 }
14
+ class Analyzer::ImageAnalyzer < Analyzer
15
+ @@supported_analyzers = {}
16
+
17
+ def metadata
18
+ return {} unless analyzer_supported?
19
+
20
+ read_media do |media|
21
+ if rotated_image?(media)
22
+ { width: media.height, height: media.width }
23
+ else
24
+ { width: media.width, height: media.height }
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def analyzer_supported?
32
+ if @@supported_analyzers.key?(self)
33
+ @@supported_analyzers.fetch(self)
34
+ else
35
+ @@supported_analyzers[self] = supported?
36
+ end
37
+ end
38
+
39
+ # Override this method in a concrete subclass. Have it return true if the image is rotated.
40
+ def rotated_image?(media)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # Override this method in a concrete subclass. Have it return true if the analyzer is supported.
45
+ def supported?
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # = ActiveStorageValidations 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
+ # ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable).metadata
12
+ # # => {}
13
+ class Analyzer::NullAnalyzer < Analyzer
14
+ def metadata
15
+ {}
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module ActiveStorageValidations
6
+ # = ActiveStorageValidations PDF \Analyzer
7
+ #
8
+ # Extracts the following from a pdf attachable:
9
+ #
10
+ # * Width (pts) => for the first page only
11
+ # * Height (pts) => for the first page only
12
+ # * Pages (integer) => number of pages in the pdf
13
+ #
14
+ # Example:
15
+ #
16
+ # ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable).metadata
17
+ # # => { width: 150, height: 150, pages: 1 }
18
+ #
19
+ # This analyzer requires the {poppler}[https://pdf2image.readthedocs.io/en/latest/installation.html] system library, which is not provided by \Rails.
20
+ class Analyzer::PdfAnalyzer < Analyzer
21
+ def metadata
22
+ read_media do |media|
23
+ {
24
+ width: width,
25
+ height: height,
26
+ pages: pages
27
+ }.compact
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def read_media
34
+ Tempfile.create(binmode: true) do |tempfile|
35
+ begin
36
+ if media(tempfile).present?
37
+ yield media(tempfile)
38
+ else
39
+ logger.info "Skipping pdf file metadata analysis because poppler doesn't support the file"
40
+ {}
41
+ end
42
+ ensure
43
+ tempfile.close
44
+ end
45
+ end
46
+ rescue Errno::ENOENT
47
+ logger.info "Skipping pdf file metadata analysis because poppler isn't installed"
48
+ {}
49
+ end
50
+
51
+ def media_from_path(path)
52
+ instrument(File.basename(pdfinfo_path)) do
53
+ stdout, _stderr, status = Open3.capture3(
54
+ pdfinfo_path,
55
+ path
56
+ )
57
+
58
+ status.success? ? stdout_to_hash(stdout) : nil
59
+ end
60
+ end
61
+
62
+ def stdout_to_hash(stdout)
63
+ stdout.lines.each_with_object({}) do |line, hash|
64
+ key, value = line.strip.split(":", 2)
65
+ hash[normalize_stdout_key(key)] = value.strip if key && value
66
+ end
67
+ end
68
+
69
+ def normalize_stdout_key(key)
70
+ key.strip.underscore.gsub(/\s+/, "_").gsub(/"/, "")
71
+ end
72
+
73
+ def pdfinfo_path
74
+ ActiveStorage.paths[:pdfinfo] || "pdfinfo"
75
+ end
76
+
77
+ def width
78
+ @media["page_size"].scan(/\d+/)[0].to_i
79
+ end
80
+
81
+ def height
82
+ @media["page_size"].scan(/\d+/)[1].to_i
83
+ end
84
+
85
+ def pages
86
+ @media["pages"].to_i
87
+ end
88
+ end
89
+ end
@@ -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
@@ -0,0 +1,88 @@
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::VideoAnalyzer 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 String content type.
24
+ def content_type
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # Override this method in a concrete subclass. Have it return a Hash of metadata.
29
+ def metadata
30
+ raise NotImplementedError
31
+ end
32
+
33
+ private
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
+ # rubocop:disable Metrics/MethodLength
41
+ def media(tempfile)
42
+ @media ||= case @attachable
43
+ when ActiveStorage::Blob, String
44
+ blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
45
+ media_from_tempfile_path(tempfile, blob)
46
+ when Hash
47
+ io = @attachable[:io]
48
+ if io.is_a?(StringIO)
49
+ media_from_tempfile_path(tempfile, io)
50
+ else
51
+ File.open(io) do |file|
52
+ media_from_path(file.path)
53
+ end
54
+ end
55
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
56
+ media_from_path(@attachable.path)
57
+ when File
58
+ supports_file_attachment? ? media_from_path(@attachable.path) : raise_rails_like_error(@attachable)
59
+ when Pathname
60
+ supports_pathname_attachment? ? media_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
61
+ else
62
+ raise_rails_like_error(@attachable)
63
+ end
64
+ end
65
+
66
+ def media_from_tempfile_path(tempfile, file_representation)
67
+ if file_representation.is_a?(ActiveStorage::Blob)
68
+ file_representation.download { |chunk| tempfile.write(chunk) }
69
+ else
70
+ IO.copy_stream(file_representation, tempfile)
71
+ file_representation.rewind
72
+ end
73
+
74
+ tempfile.flush
75
+ tempfile.rewind
76
+ media_from_path(tempfile.path)
77
+ end
78
+
79
+ # Override this method in a concrete subclass. Have it return a media object.
80
+ def media_from_path(path)
81
+ raise NotImplementedError
82
+ end
83
+
84
+ def instrument(analyzer, &block)
85
+ ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
86
+ end
87
+ end
88
+ end
@@ -1,97 +1,157 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'metadata.rb'
4
-
5
- module ActiveStorageValidations
6
- class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
7
- include OptionProcUnfolding
8
-
9
- AVAILABLE_CHECKS = %i[with].freeze
10
- PRECISION = 3
11
-
12
- def check_validity!
13
- return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
14
- raise ArgumentError, 'You must pass :with to the validator'
15
- end
16
-
17
- if Rails.gem_version >= Gem::Version.new('6.0.0')
18
- def validate_each(record, attribute, _value)
19
- return true unless record.send(attribute).attached?
20
-
21
- changes = record.attachment_changes[attribute.to_s]
22
- return true if changes.blank?
23
-
24
- files = Array.wrap(changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable)
25
-
26
- files.each do |file|
27
- metadata = Metadata.new(file).metadata
28
- next if is_valid?(record, attribute, metadata)
29
- break
30
- end
31
- end
32
- else
33
- # Rails 5
34
- def validate_each(record, attribute, _value)
35
- return true unless record.send(attribute).attached?
36
-
37
- files = Array.wrap(record.send(attribute))
38
-
39
- files.each do |file|
40
- # Analyze file first if not analyzed to get all required metadata.
41
- file.analyze; file.reload unless file.analyzed?
42
- metadata = file.metadata
43
-
44
- next if is_valid?(record, attribute, metadata)
45
- break
46
- end
47
- end
48
- end
49
-
50
-
51
- private
52
-
53
-
54
- def is_valid?(record, attribute, metadata)
55
- flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
56
- if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
57
- add_error(record, attribute, :image_metadata_missing, flat_options[:with])
58
- return false
59
- end
60
-
61
- case flat_options[:with]
62
- when :square
63
- return true if metadata[:width] == metadata[:height]
64
- add_error(record, attribute, :aspect_ratio_not_square, flat_options[:with])
65
-
66
- when :portrait
67
- return true if metadata[:height] > metadata[:width]
68
- add_error(record, attribute, :aspect_ratio_not_portrait, flat_options[:with])
69
-
70
- when :landscape
71
- return true if metadata[:width] > metadata[:height]
72
- add_error(record, attribute, :aspect_ratio_not_landscape, flat_options[:with])
73
-
74
- else
75
- if flat_options[:with] =~ /is_(\d*)_(\d*)/
76
- x = $1.to_i
77
- y = $2.to_i
78
-
79
- return true if (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
80
-
81
- add_error(record, attribute, :aspect_ratio_is_not, "#{x}x#{y}")
82
- else
83
- add_error(record, attribute, :aspect_ratio_unknown, flat_options[:with])
84
- end
85
- end
86
- false
87
- end
88
-
89
-
90
- def add_error(record, attribute, default_message, interpolate)
91
- message = options[:message].presence || default_message
92
- return if record.errors.added?(attribute, message)
93
- record.errors.add(attribute, message, aspect_ratio: interpolate)
94
- end
95
-
96
- end
97
- 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_present?(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_and_populate_error_options(options, attachable)
68
+ errors_options[:width] = metadata[:width]
69
+ errors_options[:height] = metadata[:height]
70
+ error_type = aspect_ratio_error_mapping
71
+ add_error(record, attribute, error_type, **errors_options)
72
+ false
73
+ end
74
+
75
+ def aspect_ratio_error_mapping
76
+ return :aspect_ratio_invalid if @authorized_aspect_ratios.many?
77
+
78
+ aspect_ratio = @authorized_aspect_ratios.first
79
+ NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
80
+ end
81
+
82
+ def media_metadata_present?(record, attribute, attachable, metadata)
83
+ return true if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
84
+
85
+ add_media_metadata_missing_error(record, attribute, attachable)
86
+ false
87
+ end
88
+
89
+ def initialize_and_populate_error_options(options, attachable)
90
+ errors_options = initialize_error_options(options, attachable)
91
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
92
+ errors_options
93
+ end
94
+
95
+ def valid_square_aspect_ratio?(metadata)
96
+ metadata[:width] == metadata[:height]
97
+ end
98
+
99
+ def valid_portrait_aspect_ratio?(metadata)
100
+ metadata[:width] < metadata[:height]
101
+ end
102
+
103
+ def valid_landscape_aspect_ratio?(metadata)
104
+ metadata[:width] > metadata[:height]
105
+ end
106
+
107
+ def valid_regex_aspect_ratio?(aspect_ratio, metadata)
108
+ aspect_ratio =~ ASPECT_RATIO_REGEX
109
+ x = ::Regexp.last_match(1).to_i
110
+ y = ::Regexp.last_match(2).to_i
111
+
112
+ x > 0 && y > 0 && (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
113
+ end
114
+
115
+ def ensure_at_least_one_validator_option
116
+ return if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
117
+
118
+ raise ArgumentError, "You must pass either :with or :in to the validator"
119
+ end
120
+
121
+ def ensure_aspect_ratio_validity
122
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
123
+
124
+ authorized_aspect_ratios_from_options(options).each do |aspect_ratio|
125
+ unless NAMED_ASPECT_RATIOS.include?(aspect_ratio) || aspect_ratio =~ ASPECT_RATIO_REGEX
126
+ raise ArgumentError, invalid_aspect_ratio_message
127
+ end
128
+ end
129
+ end
130
+
131
+ def invalid_aspect_ratio_message
132
+ <<~ERROR_MESSAGE
133
+ You must pass a valid aspect ratio to the validator
134
+ It should either be a named aspect ratio (#{NAMED_ASPECT_RATIOS.join(', ')})
135
+ Or an aspect ratio like 'is_16_9' (matching /#{ASPECT_RATIO_REGEX.source}/)
136
+ ERROR_MESSAGE
137
+ end
138
+
139
+ def authorized_aspect_ratios_from_options(flat_options)
140
+ (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in]))
141
+ end
142
+
143
+ def string_aspect_ratios
144
+ @authorized_aspect_ratios.map do |aspect_ratio|
145
+ if NAMED_ASPECT_RATIOS.include?(aspect_ratio)
146
+ aspect_ratio
147
+ else
148
+ aspect_ratio =~ ASPECT_RATIO_REGEX
149
+ x = ::Regexp.last_match(1).to_i
150
+ y = ::Regexp.last_match(2).to_i
151
+
152
+ "#{x}:#{y}"
153
+ end
154
+ end.join(", ")
155
+ end
156
+ end
157
+ end