active_storage_validations 3.0.5 → 4.1.0
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/MIT-LICENSE +1 -1
- data/README.md +227 -116
- data/Rakefile +13 -15
- data/config/locales/da.yml +2 -0
- data/config/locales/de.yml +2 -0
- data/config/locales/en-GB.yml +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/config/locales/fr.yml +2 -0
- data/config/locales/it.yml +2 -0
- data/config/locales/ja.yml +2 -0
- data/config/locales/nl.yml +2 -0
- data/config/locales/pl.yml +2 -0
- data/config/locales/pt-BR.yml +2 -0
- data/config/locales/ru.yml +2 -0
- data/config/locales/sv.yml +2 -0
- data/config/locales/tr.yml +2 -0
- data/config/locales/uk.yml +2 -0
- data/config/locales/vi.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/lib/active_storage_validations/analyzer/audio_analyzer.rb +0 -1
- data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +31 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +44 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +15 -24
- data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +41 -7
- data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +36 -10
- data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +3 -9
- data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +5 -4
- data/lib/active_storage_validations/analyzer/video_analyzer.rb +0 -1
- data/lib/active_storage_validations/analyzer.rb +35 -3
- data/lib/active_storage_validations/content_type_validator.rb +24 -6
- data/lib/active_storage_validations/dimension_validator.rb +4 -2
- data/lib/active_storage_validations/form_builder.rb +43 -0
- data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +4 -0
- data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -1
- data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +12 -0
- data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +4 -0
- data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/shared/asv_except_onable.rb +57 -0
- data/lib/active_storage_validations/matchers/shared/asv_spoofing_protectable.rb +44 -0
- data/lib/active_storage_validations/matchers/shared/asv_timeoutable.rb +32 -0
- data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
- data/lib/active_storage_validations/matchers.rb +10 -1
- data/lib/active_storage_validations/processable_file_validator.rb +60 -1
- data/lib/active_storage_validations/railtie.rb +6 -0
- data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
- data/lib/active_storage_validations/shared/asv_attachable.rb +9 -10
- data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
- data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations/with_audio_validator.rb +37 -0
- data/lib/active_storage_validations.rb +23 -0
- metadata +27 -58
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shared/asv_active_storageable"
|
|
4
|
+
require_relative "shared/asv_allow_blankable"
|
|
5
|
+
require_relative "shared/asv_attachable"
|
|
6
|
+
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
8
|
+
require_relative "shared/asv_messageable"
|
|
9
|
+
require_relative "shared/asv_rspecable"
|
|
10
|
+
require_relative "shared/asv_timeoutable"
|
|
11
|
+
require_relative "shared/asv_validatable"
|
|
12
|
+
|
|
13
|
+
module ActiveStorageValidations
|
|
14
|
+
module Matchers
|
|
15
|
+
def validate_with_audio_of(attribute_name)
|
|
16
|
+
WithAudioValidatorMatcher.new(attribute_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class WithAudioValidatorMatcher
|
|
20
|
+
include ASVActiveStorageable
|
|
21
|
+
include ASVAllowBlankable
|
|
22
|
+
include ASVAttachable
|
|
23
|
+
include ASVContextable
|
|
24
|
+
include ASVExceptOnable
|
|
25
|
+
include ASVMessageable
|
|
26
|
+
include ASVRspecable
|
|
27
|
+
include ASVTimeoutable
|
|
28
|
+
include ASVValidatable
|
|
29
|
+
|
|
30
|
+
def initialize(attribute_name)
|
|
31
|
+
initialize_allow_blankable
|
|
32
|
+
initialize_contextable
|
|
33
|
+
initialize_except_onable
|
|
34
|
+
initialize_messageable
|
|
35
|
+
initialize_rspecable
|
|
36
|
+
initialize_timeoutable
|
|
37
|
+
@attribute_name = attribute_name
|
|
38
|
+
@expected_audio = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def description
|
|
42
|
+
"validate that :#{@attribute_name} #{audio_expectation}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failure_message
|
|
46
|
+
"is expected to validate that :#{@attribute_name} #{audio_expectation}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def without_audio
|
|
50
|
+
@expected_audio = false
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def matches?(subject)
|
|
55
|
+
@subject = subject.is_a?(Class) ? subject.new : subject
|
|
56
|
+
|
|
57
|
+
is_a_valid_active_storage_attribute? &&
|
|
58
|
+
is_context_valid? &&
|
|
59
|
+
is_except_on_valid? &&
|
|
60
|
+
is_allowing_blank? &&
|
|
61
|
+
is_timeout_valid? &&
|
|
62
|
+
is_custom_message_valid? &&
|
|
63
|
+
is_valid_with_expected_audio? &&
|
|
64
|
+
is_invalid_with_unexpected_audio?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def audio_expectation
|
|
70
|
+
@expected_audio ? "has an audio track" : "has no audio track"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def is_valid_with_expected_audio?
|
|
74
|
+
validation_passes_with_audio?(@expected_audio)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def is_invalid_with_unexpected_audio?
|
|
78
|
+
!validation_passes_with_audio?(!@expected_audio)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def is_custom_message_valid?
|
|
82
|
+
return true unless @custom_message
|
|
83
|
+
|
|
84
|
+
with_audio_metadata(false) do
|
|
85
|
+
attach_file(video_file)
|
|
86
|
+
validate
|
|
87
|
+
detach_file
|
|
88
|
+
has_an_error_message_which_is_custom_message?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validation_passes_with_audio?(audio)
|
|
93
|
+
with_audio_metadata(audio) do
|
|
94
|
+
attach_file(video_file)
|
|
95
|
+
validate
|
|
96
|
+
detach_file
|
|
97
|
+
is_valid?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def with_audio_metadata(audio, &block)
|
|
102
|
+
Matchers.mock_metadata(io, { audio: audio }, &block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def video_file
|
|
106
|
+
{
|
|
107
|
+
io: io,
|
|
108
|
+
filename: "test.mp4",
|
|
109
|
+
content_type: "video/mp4"
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -7,6 +7,7 @@ require "active_storage_validations/matchers/limit_validator_matcher"
|
|
|
7
7
|
require "active_storage_validations/matchers/content_type_validator_matcher"
|
|
8
8
|
require "active_storage_validations/matchers/dimension_validator_matcher"
|
|
9
9
|
require "active_storage_validations/matchers/duration_validator_matcher"
|
|
10
|
+
require "active_storage_validations/matchers/with_audio_validator_matcher"
|
|
10
11
|
require "active_storage_validations/matchers/size_validator_matcher"
|
|
11
12
|
require "active_storage_validations/matchers/total_size_validator_matcher"
|
|
12
13
|
require "active_storage_validations/matchers/pages_validator_matcher"
|
|
@@ -29,7 +30,15 @@ module ActiveStorageValidations
|
|
|
29
30
|
|
|
30
31
|
def self.mock_metadata(attachment, metadata = {})
|
|
31
32
|
asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
|
|
32
|
-
|
|
33
|
+
merged = asv_metadata_available_keys.merge(metadata)
|
|
34
|
+
# Stubbing Analyzer.new also covers content-type sniffers (File / Magika),
|
|
35
|
+
# which call #content_type — not #metadata. Default to the dummy file type
|
|
36
|
+
# so stacked validators with spoofing_protection still pass under the mock.
|
|
37
|
+
detected_content_type = merged[:content_type].presence || "image/png"
|
|
38
|
+
mock = Struct.new(:metadata, :content_type).new(
|
|
39
|
+
merged,
|
|
40
|
+
{ content_type: detected_content_type, content_type_backend: "file" }
|
|
41
|
+
)
|
|
33
42
|
|
|
34
43
|
stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
|
|
35
44
|
yield
|
|
@@ -19,6 +19,28 @@ module ActiveStorageValidations
|
|
|
19
19
|
].freeze
|
|
20
20
|
METADATA_KEYS = %i[].freeze
|
|
21
21
|
|
|
22
|
+
# Content types whose libvips loaders are marked untrusted / unfuzzed
|
|
23
|
+
# (VIPS_OPERATION_UNTRUSTED). After Rails calls `Vips.block_untrusted(true)`,
|
|
24
|
+
# analysis of these types returns no width/height (empty metadata here),
|
|
25
|
+
# which is not the same as a corrupt / unprocessable file.
|
|
26
|
+
#
|
|
27
|
+
# Matches the formats called out in the Active Storage security release
|
|
28
|
+
# changelogs (7.2.3.2 / 8.0.5.1 / 8.1.3.1):
|
|
29
|
+
# https://github.com/rails/rails/blob/v8.1.3.1/activestorage/CHANGELOG.md
|
|
30
|
+
# https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm
|
|
31
|
+
VIPS_UNTRUSTED_CONTENT_TYPES = %w[
|
|
32
|
+
image/bmp
|
|
33
|
+
image/vnd.microsoft.icon
|
|
34
|
+
image/vnd.adobe.photoshop
|
|
35
|
+
image/svg+xml
|
|
36
|
+
image/jxl
|
|
37
|
+
image/jp2
|
|
38
|
+
image/x-portable-anymap
|
|
39
|
+
image/x-portable-bitmap
|
|
40
|
+
image/x-portable-graymap
|
|
41
|
+
image/x-portable-pixmap
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
22
44
|
def validate_each(record, attribute, _value)
|
|
23
45
|
return if no_attachments?(record, attribute)
|
|
24
46
|
|
|
@@ -28,10 +50,47 @@ module ActiveStorageValidations
|
|
|
28
50
|
private
|
|
29
51
|
|
|
30
52
|
def is_valid?(record, attribute, attachable, metadata)
|
|
31
|
-
return if
|
|
53
|
+
return if metadata.present?
|
|
54
|
+
return if vips_unanalyzable_by_policy?(attachable)
|
|
32
55
|
|
|
33
56
|
errors_options = initialize_error_options(options, attachable)
|
|
34
57
|
add_error(record, attribute, ERROR_TYPES.first, **errors_options)
|
|
35
58
|
end
|
|
59
|
+
|
|
60
|
+
def vips_unanalyzable_by_policy?(attachable)
|
|
61
|
+
image_processor == :vips &&
|
|
62
|
+
self.class.vips_untrusted_operations_blocked? &&
|
|
63
|
+
VIPS_UNTRUSTED_CONTENT_TYPES.include?(attachable_content_type(attachable))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# Detect whether libvips is currently blocking untrusted operations.
|
|
68
|
+
# There is no public getter for `Vips.block_untrusted`, so we probe whether
|
|
69
|
+
# an untrusted loader (svgload) is available via `vips_foreign_find_load`.
|
|
70
|
+
def vips_untrusted_operations_blocked?
|
|
71
|
+
return @vips_untrusted_operations_blocked if defined?(@vips_untrusted_operations_blocked)
|
|
72
|
+
|
|
73
|
+
@vips_untrusted_operations_blocked = detect_vips_untrusted_operations_blocked?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reset_vips_untrusted_operations_blocked_cache!
|
|
77
|
+
remove_instance_variable(:@vips_untrusted_operations_blocked) if defined?(@vips_untrusted_operations_blocked)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def detect_vips_untrusted_operations_blocked?
|
|
83
|
+
require "vips"
|
|
84
|
+
require "tempfile"
|
|
85
|
+
|
|
86
|
+
Tempfile.create([ "asv_vips_untrusted_probe", ".svg" ]) do |file|
|
|
87
|
+
file.write('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
|
|
88
|
+
file.flush
|
|
89
|
+
::Vips.vips_foreign_find_load(file.path).nil?
|
|
90
|
+
end
|
|
91
|
+
rescue LoadError, StandardError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
36
95
|
end
|
|
37
96
|
end
|
|
@@ -11,6 +11,12 @@ module ActiveStorageValidations
|
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
initializer "active_storage_validations.form_builder" do
|
|
15
|
+
ActiveSupport.on_load(:action_view) do
|
|
16
|
+
ActionView::Helpers::FormBuilder.prepend(ActiveStorageValidations::FormBuilder)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
14
20
|
initializer "active_storage_validations.extend_active_storage_blob" do
|
|
15
21
|
ActiveSupport.on_load(:active_storage_blob) do
|
|
16
22
|
include ActiveStorageValidations::ASVBlobMetadatable
|
|
@@ -17,15 +17,62 @@ module ActiveStorageValidations
|
|
|
17
17
|
# attachable with the corresponding analyzer and set the metadata in the
|
|
18
18
|
# blob.
|
|
19
19
|
def metadata_for(blob, attachable, metadata_keys)
|
|
20
|
+
if content_type_metadata_keys?(metadata_keys)
|
|
21
|
+
return content_type_metadata_for(blob, attachable)
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
return blob.active_storage_validations_metadata if blob_has_asv_metadata?(blob, metadata_keys)
|
|
21
25
|
|
|
26
|
+
new_metadata = generate_metadata_for(attachable, metadata_keys) || {}
|
|
27
|
+
blob.merge_into_active_storage_validations_metadata(new_metadata)
|
|
28
|
+
blob.save!
|
|
29
|
+
|
|
30
|
+
blob.active_storage_validations_metadata
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def content_type_metadata_keys?(metadata_keys)
|
|
34
|
+
metadata_keys == ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Same cache semantics as +metadata_for+, plus backend matching.
|
|
38
|
+
# Legacy blobs that only have +asv_content_type+ (no backend key) are treated
|
|
39
|
+
# as +:file+ — so existing file-backend analyses keep hitting the cache and
|
|
40
|
+
# are not re-analyzed / rewritten.
|
|
41
|
+
def content_type_metadata_for(blob, attachable)
|
|
42
|
+
backend = spoofing_protection_backend
|
|
43
|
+
cached = blob.active_storage_validations_metadata
|
|
44
|
+
return cached if content_type_cache_hit?(cached, backend)
|
|
45
|
+
|
|
46
|
+
metadata_keys = ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
|
|
22
47
|
new_metadata = generate_metadata_for(attachable, metadata_keys)
|
|
48
|
+
return failed_content_type_metadata(backend) if content_type_analysis_failed?(new_metadata)
|
|
49
|
+
|
|
23
50
|
blob.merge_into_active_storage_validations_metadata(new_metadata)
|
|
24
51
|
blob.save!
|
|
25
52
|
|
|
26
53
|
blob.active_storage_validations_metadata
|
|
27
54
|
end
|
|
28
55
|
|
|
56
|
+
def content_type_analysis_failed?(new_metadata)
|
|
57
|
+
new_metadata.blank? || new_metadata[:content_type].blank?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Do not return / persist another backend's cached type after a failed sniff
|
|
61
|
+
# (timeout, unsupported file, invalid Magika JSON, …). Fail closed for this
|
|
62
|
+
# request; leave blob metadata unchanged so a later attempt can retry.
|
|
63
|
+
def failed_content_type_metadata(backend)
|
|
64
|
+
{ content_type: nil, content_type_backend: backend.to_s }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def content_type_cache_hit?(cached, backend)
|
|
68
|
+
return false unless cached.present? && cached[:content_type].present?
|
|
69
|
+
|
|
70
|
+
# Missing / blank backend => legacy file analysis (pre-Magika support).
|
|
71
|
+
# Those blobs keep hitting the cache for +:file+ and are not rewritten.
|
|
72
|
+
stored_backend = (cached[:content_type_backend].presence || "file").to_s
|
|
73
|
+
stored_backend == backend.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
29
76
|
def blob_has_asv_metadata?(blob, metadata_keys)
|
|
30
77
|
return false unless blob.active_storage_validations_metadata.present?
|
|
31
78
|
|
|
@@ -33,7 +80,7 @@ module ActiveStorageValidations
|
|
|
33
80
|
end
|
|
34
81
|
|
|
35
82
|
def generate_metadata_for(attachable, metadata_keys)
|
|
36
|
-
if metadata_keys
|
|
83
|
+
if content_type_metadata_keys?(metadata_keys)
|
|
37
84
|
content_type_analyzer_for(attachable).content_type
|
|
38
85
|
else
|
|
39
86
|
metadata_analyzer_for(attachable).metadata
|
|
@@ -52,15 +99,15 @@ module ActiveStorageValidations
|
|
|
52
99
|
end
|
|
53
100
|
|
|
54
101
|
def pdf_analyzer_for(attachable)
|
|
55
|
-
ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable)
|
|
102
|
+
ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
56
103
|
end
|
|
57
104
|
|
|
58
105
|
def image_analyzer_for(attachable)
|
|
59
106
|
case image_processor
|
|
60
107
|
when :mini_magick
|
|
61
|
-
ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
|
|
108
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable, **analyzer_timeout_options)
|
|
62
109
|
when :vips
|
|
63
|
-
ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
|
|
110
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable, **analyzer_timeout_options)
|
|
64
111
|
end
|
|
65
112
|
end
|
|
66
113
|
|
|
@@ -71,19 +118,30 @@ module ActiveStorageValidations
|
|
|
71
118
|
end
|
|
72
119
|
|
|
73
120
|
def video_analyzer_for(attachable)
|
|
74
|
-
ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable)
|
|
121
|
+
ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
75
122
|
end
|
|
76
123
|
|
|
77
124
|
def audio_analyzer_for(attachable)
|
|
78
|
-
ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable)
|
|
125
|
+
ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
79
126
|
end
|
|
80
127
|
|
|
81
128
|
def fallback_analyzer_for(attachable)
|
|
82
|
-
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
|
|
129
|
+
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
83
130
|
end
|
|
84
131
|
|
|
85
132
|
def content_type_analyzer_for(attachable)
|
|
86
|
-
|
|
133
|
+
case spoofing_protection_backend
|
|
134
|
+
when :magika
|
|
135
|
+
ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::Magika.new(attachable, **analyzer_timeout_options)
|
|
136
|
+
else
|
|
137
|
+
ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::File.new(attachable, **analyzer_timeout_options)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Passes raw validator +timeout:+ through to analyzers. Kept out of
|
|
142
|
+
# AVAILABLE_CHECKS so ASVOptionable does not flatten it as a comparison bound.
|
|
143
|
+
def analyzer_timeout_options
|
|
144
|
+
options.key?(:timeout) ? { timeout: options[:timeout] } : {}
|
|
87
145
|
end
|
|
88
146
|
end
|
|
89
147
|
end
|
|
@@ -44,7 +44,7 @@ module ActiveStorageValidations
|
|
|
44
44
|
return to_enum(:attachables_and_blobs, record, attribute) if changes.blank? || !block_given?
|
|
45
45
|
|
|
46
46
|
if changes.is_a?(ActiveStorage::Attached::Changes::CreateMany)
|
|
47
|
-
changes.attachables.zip(changes.blobs).uniq { |
|
|
47
|
+
changes.attachables.zip(changes.blobs).uniq { |attachable, blob| attachable_blob_key(attachable, blob) }.each do |attachable, blob|
|
|
48
48
|
yield attachable, blob
|
|
49
49
|
end
|
|
50
50
|
else
|
|
@@ -52,6 +52,12 @@ module ActiveStorageValidations
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
def attachable_blob_key(attachable, blob)
|
|
56
|
+
return [ :blob_id, blob.id ] if blob&.id
|
|
57
|
+
|
|
58
|
+
[ :attachable, attachable.object_id ]
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
def changes_for(record, attribute)
|
|
56
62
|
if record.public_send(attribute).is_a?(ActiveStorage::Attached::One)
|
|
57
63
|
record.attachment_changes[attribute.to_s].presence || record.public_send(attribute)
|
|
@@ -120,14 +126,14 @@ module ActiveStorageValidations
|
|
|
120
126
|
def attachable_io(attachable, max_byte_size: nil)
|
|
121
127
|
io = case attachable
|
|
122
128
|
when ActiveStorage::Blob
|
|
123
|
-
|
|
129
|
+
max_byte_size ? attachable.download_chunk(0...max_byte_size) : attachable.download
|
|
124
130
|
when ActionDispatch::Http::UploadedFile
|
|
125
131
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
126
132
|
when Rack::Test::UploadedFile
|
|
127
133
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
128
134
|
when String
|
|
129
135
|
blob = ActiveStorage::Blob.find_signed!(attachable)
|
|
130
|
-
|
|
136
|
+
max_byte_size ? blob.download_chunk(0...max_byte_size) : blob.download
|
|
131
137
|
when Hash
|
|
132
138
|
max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
|
|
133
139
|
when File
|
|
@@ -208,13 +214,6 @@ module ActiveStorageValidations
|
|
|
208
214
|
end
|
|
209
215
|
alias :supports_pathname_attachment? :supports_file_attachment?
|
|
210
216
|
|
|
211
|
-
# Check if the current Rails version supports ActiveStorage::Blob#download_chunk
|
|
212
|
-
#
|
|
213
|
-
# https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
|
|
214
|
-
def supports_blob_download_chunk?
|
|
215
|
-
Rails.gem_version >= Gem::Version.new("7.0.0.alpha1")
|
|
216
|
-
end
|
|
217
|
-
|
|
218
217
|
# Retrieve the content_type from the file name only
|
|
219
218
|
def marcel_content_type_from_filename(attachable)
|
|
220
219
|
Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageValidations
|
|
4
|
+
# ActiveStorageValidations::ASVCommandable
|
|
5
|
+
#
|
|
6
|
+
# Runs external analyzer commands with an optional deadline and process-group
|
|
7
|
+
# kill. Used by analyzers that shell out (ffprobe, pdfinfo, file, identify).
|
|
8
|
+
module ASVCommandable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
CommandResult = Struct.new(:stdout, :stderr, :status, :timed_out, keyword_init: true) do
|
|
12
|
+
def success?
|
|
13
|
+
!timed_out && status.respond_to?(:success?) && status.success?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
TERM_GRACE_SECONDS = 1
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def run_command(*argv, payload: nil)
|
|
22
|
+
timeout = timeout_in_seconds
|
|
23
|
+
stdout_reader, stdout_writer = IO.pipe
|
|
24
|
+
stderr_reader, stderr_writer = IO.pipe
|
|
25
|
+
|
|
26
|
+
pid = Process.spawn(*argv.map(&:to_s), out: stdout_writer, err: stderr_writer, pgroup: true)
|
|
27
|
+
stdout_writer.close
|
|
28
|
+
stderr_writer.close
|
|
29
|
+
|
|
30
|
+
# Drain pipes concurrently so a chatty child cannot fill the buffer and
|
|
31
|
+
# deadlock while we wait / kill it.
|
|
32
|
+
stdout_thread = Thread.new { stdout_reader.read }
|
|
33
|
+
stderr_thread = Thread.new { stderr_reader.read }
|
|
34
|
+
|
|
35
|
+
status, timed_out = wait_for_command(pid, timeout)
|
|
36
|
+
mark_timed_out!(payload, argv.first) if timed_out
|
|
37
|
+
|
|
38
|
+
CommandResult.new(
|
|
39
|
+
stdout: stdout_thread.value,
|
|
40
|
+
stderr: stderr_thread.value,
|
|
41
|
+
status: status,
|
|
42
|
+
timed_out: timed_out
|
|
43
|
+
)
|
|
44
|
+
ensure
|
|
45
|
+
close_pipe(stdout_reader)
|
|
46
|
+
close_pipe(stderr_reader)
|
|
47
|
+
close_pipe(stdout_writer)
|
|
48
|
+
close_pipe(stderr_writer)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Waits via a reaper thread + Thread#join(timeout) so fast commands return
|
|
52
|
+
# as soon as they exit (no polling sleep). On timeout, TERM then always KILL
|
|
53
|
+
# the process group — KILL is unconditional because the direct child can exit
|
|
54
|
+
# on TERM while a grandchild forked after the group signal is still alive.
|
|
55
|
+
# The same waiter reaps so we never leave zombies.
|
|
56
|
+
def wait_for_command(pid, timeout)
|
|
57
|
+
return [ Process.wait2(pid)[1], false ] if timeout.nil?
|
|
58
|
+
|
|
59
|
+
waiter = Thread.new do
|
|
60
|
+
Process.wait2(pid)
|
|
61
|
+
rescue Errno::ECHILD
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if waiter.join(timeout)
|
|
66
|
+
return [ wait_status_from(waiter), false ]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
signal_process_group(pid, :TERM)
|
|
70
|
+
waiter.join(TERM_GRACE_SECONDS)
|
|
71
|
+
signal_process_group(pid, :KILL)
|
|
72
|
+
waiter.join
|
|
73
|
+
|
|
74
|
+
[ wait_status_from(waiter), true ]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_status_from(waiter)
|
|
78
|
+
waiter.value&.last
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def signal_process_group(pid, signal)
|
|
82
|
+
Process.kill(signal, -pid)
|
|
83
|
+
rescue Errno::ESRCH
|
|
84
|
+
# Already exited; waiter will reap.
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def close_pipe(io)
|
|
88
|
+
io&.close unless io.nil? || io.closed?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def monotonic_time
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -22,7 +22,11 @@ module ActiveStorageValidations
|
|
|
22
22
|
when Array
|
|
23
23
|
options.map { |option| flatten_options(record, option, available_checks) }
|
|
24
24
|
else
|
|
25
|
-
options.is_a?(Proc)
|
|
25
|
+
if options.is_a?(Proc)
|
|
26
|
+
options.arity == 0 ? options.call : options.call(record)
|
|
27
|
+
else
|
|
28
|
+
options
|
|
29
|
+
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
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_symbolizable"
|
|
8
|
+
|
|
9
|
+
module ActiveStorageValidations
|
|
10
|
+
class WithAudioValidator < ActiveModel::EachValidator # :nodoc
|
|
11
|
+
include ASVActiveStorageable
|
|
12
|
+
include ASVAnalyzable
|
|
13
|
+
include ASVAttachable
|
|
14
|
+
include ASVErrorable
|
|
15
|
+
include ASVSymbolizable
|
|
16
|
+
|
|
17
|
+
ERROR_TYPES = %i[audio_missing audio_present].freeze
|
|
18
|
+
METADATA_KEYS = %i[audio].freeze
|
|
19
|
+
|
|
20
|
+
def validate_each(record, attribute, _value)
|
|
21
|
+
return if no_attachments?(record, attribute)
|
|
22
|
+
|
|
23
|
+
validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def is_valid?(record, attribute, attachable, metadata)
|
|
29
|
+
expected_audio = options.fetch(:with, true)
|
|
30
|
+
return if metadata&.fetch(:audio, false) == expected_audio
|
|
31
|
+
|
|
32
|
+
errors_options = initialize_error_options(options, attachable)
|
|
33
|
+
error_type = expected_audio ? :audio_missing : :audio_present
|
|
34
|
+
add_error(record, attribute, error_type, **errors_options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -2,8 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_model"
|
|
4
4
|
require "active_support/concern"
|
|
5
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
6
|
+
require "active_support/core_ext/numeric/time"
|
|
7
|
+
|
|
8
|
+
module ActiveStorageValidations
|
|
9
|
+
# When true (default), FormBuilder#file_field automatically sets the HTML
|
|
10
|
+
# +accept+ attribute from +content_type+ validators. Can also be overridden
|
|
11
|
+
# per field with +infer_accept:+.
|
|
12
|
+
mattr_accessor :infer_file_field_accept, instance_accessor: false, default: true
|
|
13
|
+
|
|
14
|
+
# Maximum time allowed for external analyzer commands (ffprobe, pdfinfo, file,
|
|
15
|
+
# magika, MiniMagick, libvips). Set to +nil+ to disable. Per-validator +timeout:+
|
|
16
|
+
# overrides this value for the analysis that validator triggers.
|
|
17
|
+
mattr_accessor :command_timeout, instance_accessor: false, default: 10.seconds
|
|
18
|
+
|
|
19
|
+
def self.configure
|
|
20
|
+
yield self
|
|
21
|
+
end
|
|
22
|
+
end
|
|
5
23
|
|
|
6
24
|
require "active_storage_validations/analyzer"
|
|
25
|
+
require "active_storage_validations/analyzer/content_type_analyzer"
|
|
26
|
+
require "active_storage_validations/analyzer/content_type_analyzer/file"
|
|
27
|
+
require "active_storage_validations/analyzer/content_type_analyzer/magika"
|
|
7
28
|
require "active_storage_validations/analyzer/image_analyzer"
|
|
8
29
|
require "active_storage_validations/analyzer/image_analyzer/image_magick"
|
|
9
30
|
require "active_storage_validations/analyzer/image_analyzer/vips"
|
|
@@ -20,11 +41,13 @@ require "active_storage_validations/content_type_validator"
|
|
|
20
41
|
require "active_storage_validations/limit_validator"
|
|
21
42
|
require "active_storage_validations/dimension_validator"
|
|
22
43
|
require "active_storage_validations/duration_validator"
|
|
44
|
+
require "active_storage_validations/with_audio_validator"
|
|
23
45
|
require "active_storage_validations/aspect_ratio_validator"
|
|
24
46
|
require "active_storage_validations/processable_file_validator"
|
|
25
47
|
require "active_storage_validations/size_validator"
|
|
26
48
|
require "active_storage_validations/total_size_validator"
|
|
27
49
|
require "active_storage_validations/pages_validator"
|
|
28
50
|
|
|
51
|
+
require "active_storage_validations/form_builder"
|
|
29
52
|
require "active_storage_validations/engine"
|
|
30
53
|
require "active_storage_validations/railtie"
|