active_storage_validations 3.0.6 → 4.0.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 +178 -115
- data/Rakefile +13 -15
- 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.rb +9 -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 +2 -9
- 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.rb +22 -0
- metadata +25 -72
|
@@ -7,8 +7,11 @@ require_relative "shared/asv_active_storageable"
|
|
|
7
7
|
require_relative "shared/asv_allow_blankable"
|
|
8
8
|
require_relative "shared/asv_attachable"
|
|
9
9
|
require_relative "shared/asv_contextable"
|
|
10
|
+
require_relative "shared/asv_except_onable"
|
|
10
11
|
require_relative "shared/asv_messageable"
|
|
11
12
|
require_relative "shared/asv_rspecable"
|
|
13
|
+
require_relative "shared/asv_spoofing_protectable"
|
|
14
|
+
require_relative "shared/asv_timeoutable"
|
|
12
15
|
require_relative "shared/asv_validatable"
|
|
13
16
|
|
|
14
17
|
module ActiveStorageValidations
|
|
@@ -22,15 +25,21 @@ module ActiveStorageValidations
|
|
|
22
25
|
include ASVAllowBlankable
|
|
23
26
|
include ASVAttachable
|
|
24
27
|
include ASVContextable
|
|
28
|
+
include ASVExceptOnable
|
|
25
29
|
include ASVMessageable
|
|
26
30
|
include ASVRspecable
|
|
31
|
+
include ASVSpoofingProtectable
|
|
32
|
+
include ASVTimeoutable
|
|
27
33
|
include ASVValidatable
|
|
28
34
|
|
|
29
35
|
def initialize(attribute_name)
|
|
30
36
|
initialize_allow_blankable
|
|
31
37
|
initialize_contextable
|
|
38
|
+
initialize_except_onable
|
|
32
39
|
initialize_messageable
|
|
33
40
|
initialize_rspecable
|
|
41
|
+
initialize_spoofing_protectable
|
|
42
|
+
initialize_timeoutable
|
|
34
43
|
@attribute_name = attribute_name
|
|
35
44
|
@allowed_content_types = @rejected_content_types = []
|
|
36
45
|
end
|
|
@@ -62,7 +71,10 @@ module ActiveStorageValidations
|
|
|
62
71
|
|
|
63
72
|
is_a_valid_active_storage_attribute? &&
|
|
64
73
|
is_context_valid? &&
|
|
74
|
+
is_except_on_valid? &&
|
|
65
75
|
is_allowing_blank? &&
|
|
76
|
+
is_timeout_valid? &&
|
|
77
|
+
is_spoofing_protection_valid? &&
|
|
66
78
|
is_custom_message_valid? &&
|
|
67
79
|
all_allowed_content_types_allowed? &&
|
|
68
80
|
all_rejected_content_types_rejected?
|
|
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
10
|
+
require_relative "shared/asv_timeoutable"
|
|
9
11
|
require_relative "shared/asv_validatable"
|
|
10
12
|
|
|
11
13
|
module ActiveStorageValidations
|
|
@@ -19,15 +21,19 @@ module ActiveStorageValidations
|
|
|
19
21
|
include ASVAllowBlankable
|
|
20
22
|
include ASVAttachable
|
|
21
23
|
include ASVContextable
|
|
24
|
+
include ASVExceptOnable
|
|
22
25
|
include ASVMessageable
|
|
23
26
|
include ASVRspecable
|
|
27
|
+
include ASVTimeoutable
|
|
24
28
|
include ASVValidatable
|
|
25
29
|
|
|
26
30
|
def initialize(attribute_name)
|
|
27
31
|
initialize_allow_blankable
|
|
28
32
|
initialize_contextable
|
|
33
|
+
initialize_except_onable
|
|
29
34
|
initialize_messageable
|
|
30
35
|
initialize_rspecable
|
|
36
|
+
initialize_timeoutable
|
|
31
37
|
@attribute_name = attribute_name
|
|
32
38
|
@width_min = @width_max = @height_min = @height_max = nil
|
|
33
39
|
end
|
|
@@ -87,7 +93,9 @@ module ActiveStorageValidations
|
|
|
87
93
|
|
|
88
94
|
is_a_valid_active_storage_attribute? &&
|
|
89
95
|
is_context_valid? &&
|
|
96
|
+
is_except_on_valid? &&
|
|
90
97
|
is_allowing_blank? &&
|
|
98
|
+
is_timeout_valid? &&
|
|
91
99
|
is_custom_message_valid? &&
|
|
92
100
|
width_not_smaller_than_min? &&
|
|
93
101
|
width_larger_than_min? &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base_comparison_validator_matcher"
|
|
4
|
+
require_relative "shared/asv_timeoutable"
|
|
4
5
|
|
|
5
6
|
module ActiveStorageValidations
|
|
6
7
|
module Matchers
|
|
@@ -9,6 +10,13 @@ module ActiveStorageValidations
|
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
class DurationValidatorMatcher < BaseComparisonValidatorMatcher
|
|
13
|
+
include ASVTimeoutable
|
|
14
|
+
|
|
15
|
+
def initialize(attribute_name)
|
|
16
|
+
super
|
|
17
|
+
initialize_timeoutable
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
def description
|
|
13
21
|
"validate file duration of :#{@attribute_name}"
|
|
14
22
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
9
10
|
require_relative "shared/asv_validatable"
|
|
@@ -19,6 +20,7 @@ module ActiveStorageValidations
|
|
|
19
20
|
include ASVAllowBlankable
|
|
20
21
|
include ASVAttachable
|
|
21
22
|
include ASVContextable
|
|
23
|
+
include ASVExceptOnable
|
|
22
24
|
include ASVMessageable
|
|
23
25
|
include ASVRspecable
|
|
24
26
|
include ASVValidatable
|
|
@@ -26,6 +28,7 @@ module ActiveStorageValidations
|
|
|
26
28
|
def initialize(attribute_name)
|
|
27
29
|
initialize_allow_blankable
|
|
28
30
|
initialize_contextable
|
|
31
|
+
initialize_except_onable
|
|
29
32
|
initialize_messageable
|
|
30
33
|
initialize_rspecable
|
|
31
34
|
@attribute_name = attribute_name
|
|
@@ -57,6 +60,7 @@ module ActiveStorageValidations
|
|
|
57
60
|
|
|
58
61
|
is_a_valid_active_storage_attribute? &&
|
|
59
62
|
is_context_valid? &&
|
|
63
|
+
is_except_on_valid? &&
|
|
60
64
|
is_custom_message_valid? &&
|
|
61
65
|
file_count_not_smaller_than_min? &&
|
|
62
66
|
file_count_equal_min? &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base_comparison_validator_matcher"
|
|
4
|
+
require_relative "shared/asv_timeoutable"
|
|
4
5
|
|
|
5
6
|
module ActiveStorageValidations
|
|
6
7
|
module Matchers
|
|
@@ -9,6 +10,13 @@ module ActiveStorageValidations
|
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
class PagesValidatorMatcher < BaseComparisonValidatorMatcher
|
|
13
|
+
include ASVTimeoutable
|
|
14
|
+
|
|
15
|
+
def initialize(attribute_name)
|
|
16
|
+
super
|
|
17
|
+
initialize_timeoutable
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
def description
|
|
13
21
|
"validate file number of pages of :#{@attribute_name}"
|
|
14
22
|
end
|
|
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
10
|
+
require_relative "shared/asv_timeoutable"
|
|
9
11
|
require_relative "shared/asv_validatable"
|
|
10
12
|
|
|
11
13
|
module ActiveStorageValidations
|
|
@@ -19,15 +21,19 @@ module ActiveStorageValidations
|
|
|
19
21
|
include ASVAllowBlankable
|
|
20
22
|
include ASVAttachable
|
|
21
23
|
include ASVContextable
|
|
24
|
+
include ASVExceptOnable
|
|
22
25
|
include ASVMessageable
|
|
23
26
|
include ASVRspecable
|
|
27
|
+
include ASVTimeoutable
|
|
24
28
|
include ASVValidatable
|
|
25
29
|
|
|
26
30
|
def initialize(attribute_name)
|
|
27
31
|
initialize_allow_blankable
|
|
28
32
|
initialize_contextable
|
|
33
|
+
initialize_except_onable
|
|
29
34
|
initialize_messageable
|
|
30
35
|
initialize_rspecable
|
|
36
|
+
initialize_timeoutable
|
|
31
37
|
@attribute_name = attribute_name
|
|
32
38
|
end
|
|
33
39
|
|
|
@@ -44,6 +50,8 @@ module ActiveStorageValidations
|
|
|
44
50
|
|
|
45
51
|
is_a_valid_active_storage_attribute? &&
|
|
46
52
|
is_context_valid? &&
|
|
53
|
+
is_except_on_valid? &&
|
|
54
|
+
is_timeout_valid? &&
|
|
47
55
|
is_custom_message_valid? &&
|
|
48
56
|
is_valid_when_image_processable? &&
|
|
49
57
|
is_invalid_when_file_not_processable?
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVExceptOnable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
def initialize_except_onable
|
|
11
|
+
@except_on = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def except_on(context)
|
|
15
|
+
@except_on = context
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def is_except_on_valid?
|
|
22
|
+
return true if !@except_on && attribute_validators.none? { |validator| validator.options[:except_on] }
|
|
23
|
+
|
|
24
|
+
ensure_except_on_present!
|
|
25
|
+
ensure_except_on_valid!
|
|
26
|
+
|
|
27
|
+
if @except_on.is_a?(Array)
|
|
28
|
+
(validator_except_on_contexts & @except_on.map(&:to_s)) == validator_except_on_contexts || raise_except_on_not_listed_error
|
|
29
|
+
elsif @except_on.is_a?(Symbol)
|
|
30
|
+
validator_except_on_contexts.include?(@except_on.to_s) || raise_except_on_not_listed_error
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ensure_except_on_present!
|
|
35
|
+
raise ArgumentError, "This validator matcher needs the #except_on option to work since its validator has one" if !@except_on && attribute_validators.all? { |validator| validator.options[:except_on] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ensure_except_on_valid!
|
|
39
|
+
raise ArgumentError, "This validator matcher option only allows a symbol or an array" if !(@except_on.is_a?(Symbol) || @except_on.is_a?(Array))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validator_except_on_contexts
|
|
43
|
+
attribute_validators.map do |validator|
|
|
44
|
+
case validator.options[:except_on]
|
|
45
|
+
when Array then validator.options[:except_on].map { |context| context.to_s }
|
|
46
|
+
when NilClass then nil
|
|
47
|
+
else validator.options[:except_on].to_s
|
|
48
|
+
end
|
|
49
|
+
end.flatten.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def raise_except_on_not_listed_error
|
|
53
|
+
raise ArgumentError, "One of the provided contexts to the #except_on method is not found in any of the listed contexts for this attribute"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVSpoofingProtectable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
UNSET = Object.new.freeze
|
|
11
|
+
|
|
12
|
+
def initialize_spoofing_protectable
|
|
13
|
+
@spoofing_protection = UNSET
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def spoofing_protection(value = true)
|
|
17
|
+
@spoofing_protection = value
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def is_spoofing_protection_valid?
|
|
24
|
+
return true if @spoofing_protection.equal?(UNSET)
|
|
25
|
+
|
|
26
|
+
expected = normalize_spoofing_protection(@spoofing_protection)
|
|
27
|
+
attribute_validators.any? do |validator|
|
|
28
|
+
next false unless validator.options.key?(:spoofing_protection)
|
|
29
|
+
|
|
30
|
+
normalize_spoofing_protection(validator.options[:spoofing_protection]) == expected
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_spoofing_protection(value)
|
|
35
|
+
case value
|
|
36
|
+
when true, :file then :file
|
|
37
|
+
when :magika then :magika
|
|
38
|
+
when false then false
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVTimeoutable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
UNSET = Object.new.freeze
|
|
11
|
+
|
|
12
|
+
def initialize_timeoutable
|
|
13
|
+
@timeout = UNSET
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def timeout(value)
|
|
17
|
+
@timeout = value
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def is_timeout_valid?
|
|
24
|
+
return true if @timeout.equal?(UNSET)
|
|
25
|
+
|
|
26
|
+
attribute_validators.any? do |validator|
|
|
27
|
+
validator.options.key?(:timeout) && validator.options[:timeout] == @timeout
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -29,7 +29,15 @@ module ActiveStorageValidations
|
|
|
29
29
|
|
|
30
30
|
def self.mock_metadata(attachment, metadata = {})
|
|
31
31
|
asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
|
|
32
|
-
|
|
32
|
+
merged = asv_metadata_available_keys.merge(metadata)
|
|
33
|
+
# Stubbing Analyzer.new also covers content-type sniffers (File / Magika),
|
|
34
|
+
# which call #content_type — not #metadata. Default to the dummy file type
|
|
35
|
+
# so stacked validators with spoofing_protection still pass under the mock.
|
|
36
|
+
detected_content_type = merged[:content_type].presence || "image/png"
|
|
37
|
+
mock = Struct.new(:metadata, :content_type).new(
|
|
38
|
+
merged,
|
|
39
|
+
{ content_type: detected_content_type, content_type_backend: "file" }
|
|
40
|
+
)
|
|
33
41
|
|
|
34
42
|
stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
|
|
35
43
|
yield
|
|
@@ -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
|
|
@@ -126,14 +126,14 @@ module ActiveStorageValidations
|
|
|
126
126
|
def attachable_io(attachable, max_byte_size: nil)
|
|
127
127
|
io = case attachable
|
|
128
128
|
when ActiveStorage::Blob
|
|
129
|
-
|
|
129
|
+
max_byte_size ? attachable.download_chunk(0...max_byte_size) : attachable.download
|
|
130
130
|
when ActionDispatch::Http::UploadedFile
|
|
131
131
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
132
132
|
when Rack::Test::UploadedFile
|
|
133
133
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
134
134
|
when String
|
|
135
135
|
blob = ActiveStorage::Blob.find_signed!(attachable)
|
|
136
|
-
|
|
136
|
+
max_byte_size ? blob.download_chunk(0...max_byte_size) : blob.download
|
|
137
137
|
when Hash
|
|
138
138
|
max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
|
|
139
139
|
when File
|
|
@@ -214,13 +214,6 @@ module ActiveStorageValidations
|
|
|
214
214
|
end
|
|
215
215
|
alias :supports_pathname_attachment? :supports_file_attachment?
|
|
216
216
|
|
|
217
|
-
# Check if the current Rails version supports ActiveStorage::Blob#download_chunk
|
|
218
|
-
#
|
|
219
|
-
# https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
|
|
220
|
-
def supports_blob_download_chunk?
|
|
221
|
-
Rails.gem_version >= Gem::Version.new("7.0.0.alpha1")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
217
|
# Retrieve the content_type from the file name only
|
|
225
218
|
def marcel_content_type_from_filename(attachable)
|
|
226
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
|
|
@@ -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"
|
|
@@ -26,5 +47,6 @@ require "active_storage_validations/size_validator"
|
|
|
26
47
|
require "active_storage_validations/total_size_validator"
|
|
27
48
|
require "active_storage_validations/pages_validator"
|
|
28
49
|
|
|
50
|
+
require "active_storage_validations/form_builder"
|
|
29
51
|
require "active_storage_validations/engine"
|
|
30
52
|
require "active_storage_validations/railtie"
|