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.
- checksums.yaml +4 -4
- data/README.md +619 -213
- data/config/locales/da.yml +50 -30
- data/config/locales/de.yml +50 -30
- data/config/locales/en.yml +50 -30
- data/config/locales/es.yml +50 -30
- data/config/locales/fr.yml +50 -30
- data/config/locales/it.yml +50 -30
- data/config/locales/ja.yml +50 -30
- data/config/locales/nl.yml +50 -30
- data/config/locales/pl.yml +50 -30
- data/config/locales/pt-BR.yml +50 -30
- data/config/locales/ru.yml +50 -30
- data/config/locales/sv.yml +50 -30
- data/config/locales/tr.yml +50 -30
- data/config/locales/uk.yml +50 -30
- data/config/locales/vi.yml +50 -30
- data/config/locales/zh-CN.yml +50 -30
- data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
- data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +4 -4
- data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +11 -12
- data/lib/active_storage_validations/analyzer/image_analyzer.rb +9 -53
- data/lib/active_storage_validations/analyzer/null_analyzer.rb +2 -2
- data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
- data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
- data/lib/active_storage_validations/analyzer.rb +54 -1
- data/lib/active_storage_validations/aspect_ratio_validator.rb +15 -11
- data/lib/active_storage_validations/{base_size_validator.rb → base_comparison_validator.rb} +18 -16
- data/lib/active_storage_validations/content_type_validator.rb +56 -23
- data/lib/active_storage_validations/dimension_validator.rb +20 -19
- data/lib/active_storage_validations/duration_validator.rb +55 -0
- data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +49 -0
- data/lib/active_storage_validations/{marcel_extensor.rb → extensors/asv_marcelable.rb} +3 -0
- data/lib/active_storage_validations/limit_validator.rb +14 -2
- data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +1 -1
- data/lib/active_storage_validations/matchers/{base_size_validator_matcher.rb → base_comparison_validator_matcher.rb} +31 -25
- data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +7 -3
- data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +1 -1
- data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
- data/lib/active_storage_validations/matchers/{processable_image_validator_matcher.rb → processable_file_validator_matcher.rb} +5 -5
- data/lib/active_storage_validations/matchers/size_validator_matcher.rb +18 -2
- data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +18 -2
- data/lib/active_storage_validations/matchers.rb +5 -3
- data/lib/active_storage_validations/{processable_image_validator.rb → processable_file_validator.rb} +4 -3
- data/lib/active_storage_validations/railtie.rb +5 -0
- data/lib/active_storage_validations/shared/asv_analyzable.rb +38 -3
- data/lib/active_storage_validations/shared/asv_attachable.rb +36 -15
- data/lib/active_storage_validations/size_validator.rb +11 -3
- data/lib/active_storage_validations/total_size_validator.rb +9 -3
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations.rb +7 -3
- metadata +14 -8
- 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
|
-
# =
|
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
|
-
#
|
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
|
-
|
21
|
-
if rotated_image?(
|
22
|
-
{ width:
|
20
|
+
read_media do |media|
|
21
|
+
if rotated_image?(media)
|
22
|
+
{ width: media.height, height: media.width }
|
23
23
|
else
|
24
|
-
{ width:
|
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
|
-
|
78
|
-
|
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
|
-
# =
|
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
|
-
#
|
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::
|
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
|
-
|
26
|
+
aspect_ratio_not_x_y
|
27
27
|
aspect_ratio_invalid
|
28
|
-
|
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
|
-
!
|
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
|
-
|
68
|
-
|
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
|
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}" : :
|
80
|
+
NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
|
77
81
|
end
|
78
82
|
|
79
|
-
def
|
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[:
|
84
|
-
add_error(record, attribute, :
|
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
|
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 ==
|
27
|
-
raise NotImplementedError, '
|
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?(
|
41
|
-
return false if
|
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?(
|
42
|
+
flat_options[:between].include?(value)
|
45
43
|
elsif flat_options[:less_than].present?
|
46
|
-
|
44
|
+
value < flat_options[:less_than]
|
47
45
|
elsif flat_options[:less_than_or_equal_to].present?
|
48
|
-
|
46
|
+
value <= flat_options[:less_than_or_equal_to]
|
49
47
|
elsif flat_options[:greater_than].present?
|
50
|
-
|
48
|
+
value > flat_options[:greater_than]
|
51
49
|
elsif flat_options[:greater_than_or_equal_to].present?
|
52
|
-
|
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[:
|
58
|
-
errors_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
|
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
|
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 '
|
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
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
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(
|
59
|
-
@attachable_content_type =
|
60
|
-
@attachable_filename =
|
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
|
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
|
-
|
109
|
-
|
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[:
|
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
|
-
(
|
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
|
-
|
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)
|