active_storage_validations 1.4.0 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +619 -213
  3. data/config/locales/da.yml +50 -30
  4. data/config/locales/de.yml +50 -30
  5. data/config/locales/en.yml +50 -30
  6. data/config/locales/es.yml +50 -30
  7. data/config/locales/fr.yml +50 -30
  8. data/config/locales/it.yml +50 -30
  9. data/config/locales/ja.yml +50 -30
  10. data/config/locales/nl.yml +50 -30
  11. data/config/locales/pl.yml +50 -30
  12. data/config/locales/pt-BR.yml +50 -30
  13. data/config/locales/ru.yml +50 -30
  14. data/config/locales/sv.yml +50 -30
  15. data/config/locales/tr.yml +50 -30
  16. data/config/locales/uk.yml +50 -30
  17. data/config/locales/vi.yml +50 -30
  18. data/config/locales/zh-CN.yml +50 -30
  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 +4 -4
  22. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +11 -12
  23. data/lib/active_storage_validations/analyzer/image_analyzer.rb +9 -53
  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 +15 -11
  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 +56 -23
  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 +49 -0
  34. data/lib/active_storage_validations/{marcel_extensor.rb → extensors/asv_marcelable.rb} +3 -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 +5 -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_analyzable.rb +38 -3
  48. data/lib/active_storage_validations/shared/asv_attachable.rb +36 -15
  49. data/lib/active_storage_validations/size_validator.rb +11 -3
  50. data/lib/active_storage_validations/total_size_validator.rb +9 -3
  51. data/lib/active_storage_validations/version.rb +1 -1
  52. data/lib/active_storage_validations.rb +7 -3
  53. metadata +14 -8
  54. 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,7 +9,7 @@ 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
15
  @@supported_analyzers = {}
@@ -17,55 +17,17 @@ module ActiveStorageValidations
17
17
  def metadata
18
18
  return {} unless analyzer_supported?
19
19
 
20
- read_image do |image|
21
- if rotated_image?(image)
22
- { width: image.height, height: image.width }
20
+ read_media do |media|
21
+ if rotated_image?(media)
22
+ { width: media.height, height: media.width }
23
23
  else
24
- { width: image.width, height: image.height }
24
+ { width: media.width, height: media.height }
25
25
  end
26
26
  end
27
27
  end
28
28
 
29
29
  private
30
30
 
31
- def image(tempfile)
32
- case @attachable
33
- when ActiveStorage::Blob, String
34
- blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
35
- image_from_tempfile_path(tempfile, blob)
36
- when Hash
37
- io = @attachable[:io]
38
- if io.is_a?(StringIO)
39
- image_from_tempfile_path(tempfile, io)
40
- else
41
- File.open(io) do |file|
42
- image_from_path(file.path)
43
- end
44
- end
45
- when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
46
- image_from_path(@attachable.path)
47
- when File
48
- supports_file_attachment? ? image_from_path(@attachable.path) : raise_rails_like_error(@attachable)
49
- when Pathname
50
- supports_pathname_attachment? ? image_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
51
- else
52
- raise_rails_like_error(@attachable)
53
- end
54
- end
55
-
56
- def image_from_tempfile_path(tempfile, file_representation)
57
- if file_representation.is_a?(ActiveStorage::Blob)
58
- file_representation.download { |chunk| tempfile.write(chunk) }
59
- else
60
- IO.copy_stream(file_representation, tempfile)
61
- file_representation.rewind
62
- end
63
-
64
- tempfile.flush
65
- tempfile.rewind
66
- image_from_path(tempfile.path)
67
- end
68
-
69
31
  def analyzer_supported?
70
32
  if @@supported_analyzers.key?(self)
71
33
  @@supported_analyzers.fetch(self)
@@ -74,18 +36,12 @@ module ActiveStorageValidations
74
36
  end
75
37
  end
76
38
 
77
- def read_image
78
- raise NotImplementedError
79
- end
80
-
81
- def image_from_path(path)
82
- raise NotImplementedError
83
- end
84
-
85
- def rotated_image?(image)
39
+ # Override this method in a concrete subclass. Have it return true if the image is rotated.
40
+ def rotated_image?(media)
86
41
  raise NotImplementedError
87
42
  end
88
43
 
44
+ # Override this method in a concrete subclass. Have it return true if the analyzer is supported.
89
45
  def supported?
90
46
  raise NotImplementedError
91
47
  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
@@ -23,11 +23,12 @@ module ActiveStorageValidations
23
23
  aspect_ratio_not_square
24
24
  aspect_ratio_not_portrait
25
25
  aspect_ratio_not_landscape
26
- aspect_ratio_is_not
26
+ aspect_ratio_not_x_y
27
27
  aspect_ratio_invalid
28
- image_metadata_missing
28
+ media_metadata_missing
29
29
  ].freeze
30
30
  PRECISION = 3.freeze
31
+ METADATA_KEYS = %i[width height].freeze
31
32
 
32
33
  def check_validity!
33
34
  ensure_at_least_one_validator_option
@@ -41,13 +42,13 @@ module ActiveStorageValidations
41
42
  @authorized_aspect_ratios = authorized_aspect_ratios_from_options(flat_options).compact
42
43
  return if @authorized_aspect_ratios.empty?
43
44
 
44
- validate_changed_files_from_metadata(record, attribute)
45
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
45
46
  end
46
47
 
47
48
  private
48
49
 
49
50
  def is_valid?(record, attribute, attachable, metadata)
50
- !image_metadata_missing?(record, attribute, attachable, metadata) &&
51
+ !media_metadata_missing?(record, attribute, attachable, metadata) &&
51
52
  authorized_aspect_ratio?(record, attribute, attachable, metadata)
52
53
  end
53
54
 
@@ -64,24 +65,27 @@ module ActiveStorageValidations
64
65
  return true if attachable_aspect_ratio_is_authorized
65
66
 
66
67
  errors_options = initialize_error_options(options, attachable)
67
- errors_options[:aspect_ratio] = string_aspect_ratios
68
- add_error(record, attribute, aspect_ratio_error_mapping, **errors_options)
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)
69
73
  false
70
74
  end
71
75
 
72
76
  def aspect_ratio_error_mapping
73
- return :aspect_ratio_invalid unless @authorized_aspect_ratios.one?
77
+ return :aspect_ratio_invalid if @authorized_aspect_ratios.many?
74
78
 
75
79
  aspect_ratio = @authorized_aspect_ratios.first
76
- NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_is_not
80
+ NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
77
81
  end
78
82
 
79
- def image_metadata_missing?(record, attribute, attachable, metadata)
83
+ def media_metadata_missing?(record, attribute, attachable, metadata)
80
84
  return false if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
81
85
 
82
86
  errors_options = initialize_error_options(options, attachable)
83
- errors_options[:aspect_ratio] = string_aspect_ratios
84
- add_error(record, attribute, :image_metadata_missing, **errors_options)
87
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
88
+ add_error(record, attribute, :media_metadata_missing, **errors_options)
85
89
  true
86
90
  end
87
91
 
@@ -6,14 +6,12 @@ require_relative 'shared/asv_optionable'
6
6
  require_relative 'shared/asv_symbolizable'
7
7
 
8
8
  module ActiveStorageValidations
9
- class BaseSizeValidator < ActiveModel::EachValidator # :nodoc:
9
+ class BaseComparisonValidator < ActiveModel::EachValidator # :nodoc:
10
10
  include ASVActiveStorageable
11
11
  include ASVErrorable
12
12
  include ASVOptionable
13
13
  include ASVSymbolizable
14
14
 
15
- delegate :number_to_human_size, to: ActiveSupport::NumberHelper
16
-
17
15
  AVAILABLE_CHECKS = %i[
18
16
  less_than
19
17
  less_than_or_equal_to
@@ -23,8 +21,8 @@ module ActiveStorageValidations
23
21
  ].freeze
24
22
 
25
23
  def initialize(*args)
26
- if self.class == BaseSizeValidator
27
- raise NotImplementedError, 'BaseSizeValidator is an abstract class and cannot be instantiated directly.'
24
+ if self.class == BaseComparisonValidator
25
+ raise NotImplementedError, 'BaseComparisonValidator is an abstract class and cannot be instantiated directly.'
28
26
  end
29
27
  super
30
28
  end
@@ -37,32 +35,36 @@ module ActiveStorageValidations
37
35
 
38
36
  private
39
37
 
40
- def is_valid?(size, flat_options)
41
- return false if size < 0
38
+ def is_valid?(value, flat_options)
39
+ return false if value < 0
42
40
 
43
41
  if flat_options[:between].present?
44
- flat_options[:between].include?(size)
42
+ flat_options[:between].include?(value)
45
43
  elsif flat_options[:less_than].present?
46
- size < flat_options[:less_than]
44
+ value < flat_options[:less_than]
47
45
  elsif flat_options[:less_than_or_equal_to].present?
48
- size <= flat_options[:less_than_or_equal_to]
46
+ value <= flat_options[:less_than_or_equal_to]
49
47
  elsif flat_options[:greater_than].present?
50
- size > flat_options[:greater_than]
48
+ value > flat_options[:greater_than]
51
49
  elsif flat_options[:greater_than_or_equal_to].present?
52
- size >= flat_options[:greater_than_or_equal_to]
50
+ value >= flat_options[:greater_than_or_equal_to]
53
51
  end
54
52
  end
55
53
 
56
54
  def populate_error_options(errors_options, flat_options)
57
- errors_options[:min_size] = number_to_human_size(min_size(flat_options))
58
- errors_options[:max_size] = number_to_human_size(max_size(flat_options))
55
+ errors_options[:min] = format_bound_value(min(flat_options))
56
+ errors_options[:max] = format_bound_value(max(flat_options))
57
+ end
58
+
59
+ def format_bound_value
60
+ raise NotImplementedError
59
61
  end
60
62
 
61
- def min_size(flat_options)
63
+ def min(flat_options)
62
64
  flat_options[:between]&.min || flat_options[:greater_than] || flat_options[:greater_than_or_equal_to]
63
65
  end
64
66
 
65
- def max_size(flat_options)
67
+ def max(flat_options)
66
68
  flat_options[:between]&.max || flat_options[:less_than] || flat_options[:less_than_or_equal_to]
67
69
  end
68
70
  end
@@ -6,7 +6,7 @@ require_relative 'shared/asv_attachable'
6
6
  require_relative 'shared/asv_errorable'
7
7
  require_relative 'shared/asv_optionable'
8
8
  require_relative 'shared/asv_symbolizable'
9
- require_relative 'content_type_spoof_detector'
9
+ require_relative 'analyzer/content_type_analyzer'
10
10
 
11
11
  module ActiveStorageValidations
12
12
  class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
@@ -20,8 +20,9 @@ module ActiveStorageValidations
20
20
  AVAILABLE_CHECKS = %i[with in].freeze
21
21
  ERROR_TYPES = %i[
22
22
  content_type_invalid
23
- spoofed_content_type
23
+ content_type_spoofed
24
24
  ].freeze
25
+ METADATA_KEYS = %i[content_type].freeze
25
26
 
26
27
  def check_validity!
27
28
  ensure_exactly_one_validator_option
@@ -34,11 +35,9 @@ module ActiveStorageValidations
34
35
  @authorized_content_types = authorized_content_types_from_options(record)
35
36
  return if @authorized_content_types.empty?
36
37
 
37
- checked_files = disable_spoofing_protection? ? attached_files(record, attribute) : attachables_from_changes(record, attribute)
38
-
39
- checked_files.each do |file|
40
- set_attachable_cached_values(file)
41
- is_valid?(record, attribute, file)
38
+ attachables_and_blobs(record, attribute).each do |attachable, blob|
39
+ set_attachable_cached_values(blob)
40
+ is_valid?(record, attribute, attachable, blob)
42
41
  end
43
42
  end
44
43
 
@@ -55,16 +54,16 @@ module ActiveStorageValidations
55
54
  end
56
55
  end
57
56
 
58
- def set_attachable_cached_values(attachable)
59
- @attachable_content_type = disable_spoofing_protection? ? attachable.blob.content_type : attachable_content_type_rails_like(attachable)
60
- @attachable_filename = disable_spoofing_protection? ? attachable.blob.filename.to_s : attachable_filename(attachable).to_s
57
+ def set_attachable_cached_values(blob)
58
+ @attachable_content_type = blob.content_type
59
+ @attachable_filename = blob.filename.to_s
61
60
  end
62
61
 
63
62
  # Check if the provided content_type is authorized and not spoofed against
64
63
  # the file io.
65
- def is_valid?(record, attribute, attachable)
64
+ def is_valid?(record, attribute, attachable, blob)
66
65
  authorized_content_type?(record, attribute, attachable) &&
67
- not_spoofing_content_type?(record, attribute, attachable)
66
+ not_spoofing_content_type?(record, attribute, attachable, blob)
68
67
  end
69
68
 
70
69
  # Dead code that we keep here for some time, maybe we will find a solution
@@ -102,11 +101,19 @@ module ActiveStorageValidations
102
101
  false
103
102
  end
104
103
 
105
- def not_spoofing_content_type?(record, attribute, attachable)
104
+ def marcel_attachable_content_type(attachable)
105
+ Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
106
+ end
107
+
108
+ def not_spoofing_content_type?(record, attribute, attachable, blob)
106
109
  return true unless enable_spoofing_protection?
107
110
 
108
- if ContentTypeSpoofDetector.new(record, attribute, attachable).spoofed?
109
- errors_options = initialize_error_options(options, attachable)
111
+ @detected_content_type = metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
112
+
113
+ if attachable_content_type_vs_detected_content_type_mismatch?
114
+ errors_options = initialize_and_populate_error_options(options, attachable)
115
+ errors_options[:detected_content_type] = @detected_content_type
116
+ errors_options[:detected_human_content_type] = content_type_to_human_format(@detected_content_type)
110
117
  add_error(record, attribute, ERROR_TYPES.second, **errors_options)
111
118
  false
112
119
  else
@@ -114,10 +121,6 @@ module ActiveStorageValidations
114
121
  end
115
122
  end
116
123
 
117
- def marcel_attachable_content_type(attachable)
118
- Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
119
- end
120
-
121
124
  def disable_spoofing_protection?
122
125
  !enable_spoofing_protection?
123
126
  end
@@ -126,11 +129,32 @@ module ActiveStorageValidations
126
129
  options[:spoofing_protection] == true
127
130
  end
128
131
 
132
+ def attachable_content_type_vs_detected_content_type_mismatch?
133
+ @attachable_content_type.present? &&
134
+ !attachable_content_type_intersects_detected_content_type?
135
+ end
136
+
137
+ def attachable_content_type_intersects_detected_content_type?
138
+ # Ruby intersects? method is only available from 3.1
139
+ enlarged_content_type(content_type_without_parameters(@attachable_content_type)).any? do |item|
140
+ enlarged_content_type(content_type_without_parameters(@detected_content_type)).include?(item)
141
+ end
142
+ end
143
+
144
+ def enlarged_content_type(content_type)
145
+ [content_type, *parent_content_types(content_type)].compact.uniq
146
+ end
147
+
148
+ def parent_content_types(content_type)
149
+ Marcel::TYPE_PARENTS[content_type] || []
150
+ end
151
+
129
152
  def initialize_and_populate_error_options(options, attachable)
130
153
  errors_options = initialize_error_options(options, attachable)
131
154
  errors_options[:content_type] = @attachable_content_type
132
155
  errors_options[:human_content_type] = content_type_to_human_format(@attachable_content_type)
133
- errors_options[:authorized_types] = content_type_to_human_format(@authorized_content_types)
156
+ errors_options[:authorized_human_content_types] = content_type_to_human_format(@authorized_content_types)
157
+ errors_options[:count] = @authorized_content_types.size
134
158
  errors_options
135
159
  end
136
160
 
@@ -158,7 +182,7 @@ module ActiveStorageValidations
158
182
  def ensure_content_types_validity
159
183
  return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
160
184
 
161
- ([options[:with]] || options[:in]).each do |content_type|
185
+ (Array(options[:with]) + Array(options[:in])).each do |content_type|
162
186
  raise ArgumentError, invalid_content_type_option_message(content_type) if invalid_option?(content_type)
163
187
  end
164
188
  end
@@ -167,7 +191,7 @@ module ActiveStorageValidations
167
191
  if content_type.to_s.match?(/\//)
168
192
  <<~ERROR_MESSAGE
169
193
  You must pass valid content types to the validator
170
- '#{content_type}' is not found in Marcel::TYPE_EXTS
194
+ '#{content_type}' is not found in Marcel content types (Marcel::TYPE_EXTS + Marcel::MAGIC)
171
195
  ERROR_MESSAGE
172
196
  else
173
197
  <<~ERROR_MESSAGE
@@ -187,7 +211,16 @@ module ActiveStorageValidations
187
211
  end
188
212
 
189
213
  def invalid_content_type?(content_type)
190
- Marcel::TYPE_EXTS[content_type.to_s] == nil
214
+ if content_type == 'image/jpg'
215
+ raise ArgumentError, "'image/jpg' is not a valid content type, you should use 'image/jpeg' instead"
216
+ end
217
+
218
+ all_available_marcel_content_types.keys.exclude?(content_type.to_s)
219
+ end
220
+
221
+ def all_available_marcel_content_types
222
+ @all_available_marcel_content_types ||= Marcel::MAGIC.map {|dd| dd.first }
223
+ .each_with_object(Marcel::TYPE_EXTS) { |(k,v), h| h[k] = v unless h.key?(k) }
191
224
  end
192
225
 
193
226
  def invalid_extension?(content_type)