active_storage_validations 1.4.0 → 2.0.1

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 (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)