active_storage_validations 4.0.0 → 4.1.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 +71 -8
- 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 +4 -2
- 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 +7 -1
- data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +0 -2
- data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +0 -2
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +3 -0
- data/lib/active_storage_validations/analyzer/image_analyzer.rb +16 -6
- data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +11 -2
- data/lib/active_storage_validations/analyzer.rb +2 -35
- data/lib/active_storage_validations/aspect_ratio_validator.rb +21 -15
- data/lib/active_storage_validations/asv_attachable_adapter.rb +221 -0
- data/lib/active_storage_validations/content_type_validator.rb +40 -64
- data/lib/active_storage_validations/duration_validator.rb +3 -1
- data/lib/active_storage_validations/engine.rb +3 -0
- data/lib/active_storage_validations/extensors/asv_marcelable.rb +5 -5
- data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -4
- data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +2 -1
- data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +1 -0
- data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +1 -1
- data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
- data/lib/active_storage_validations/matchers.rb +22 -10
- data/lib/active_storage_validations/shared/asv_analyzable.rb +51 -8
- data/lib/active_storage_validations/shared/asv_attachable.rb +14 -97
- data/lib/active_storage_validations/shared/asv_errorable.rb +9 -8
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations/with_audio_validator.rb +43 -0
- data/lib/active_storage_validations.rb +1 -0
- metadata +5 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageValidations
|
|
4
|
+
# Wraps a Rails attachable so callers do not repeat the type-dispatch case tree.
|
|
5
|
+
#
|
|
6
|
+
# Must not be named +Attachable+: ActiveStorageValidations is included into
|
|
7
|
+
# Active Record, so +include Attachable+ in an app model under this namespace
|
|
8
|
+
# would resolve to this class instead of the app's concern.
|
|
9
|
+
#
|
|
10
|
+
# Supported representations: ActiveStorage::Blob, ActionDispatch::Http::UploadedFile,
|
|
11
|
+
# Rack::Test::UploadedFile, Hash, File, Pathname, and a signed blob id (String).
|
|
12
|
+
class ASVAttachableAdapter
|
|
13
|
+
def self.wrap(attachable, file_supported:)
|
|
14
|
+
handler_class_for(attachable).new(attachable, file_supported: file_supported)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Filename for error options. Unknown types return nil (never raise).
|
|
18
|
+
# File / Pathname are always accepted here so a validation error can name
|
|
19
|
+
# the file even on Rails versions that cannot attach them.
|
|
20
|
+
def self.filename_for(file)
|
|
21
|
+
case file
|
|
22
|
+
when ActiveStorage::Attached, ActiveStorage::Attachment
|
|
23
|
+
file.blob&.filename
|
|
24
|
+
else
|
|
25
|
+
return unless known?(file)
|
|
26
|
+
|
|
27
|
+
wrap(file, file_supported: true).filename
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.known?(attachable)
|
|
32
|
+
handler_class_for(attachable) != Unsupported
|
|
33
|
+
end
|
|
34
|
+
private_class_method :known?
|
|
35
|
+
|
|
36
|
+
def self.handler_class_for(attachable)
|
|
37
|
+
case attachable
|
|
38
|
+
when ActiveStorage::Blob then Blob
|
|
39
|
+
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile then Uploaded
|
|
40
|
+
when String then SignedId
|
|
41
|
+
when Hash then HashIo
|
|
42
|
+
when File then FileLike
|
|
43
|
+
when Pathname then Path
|
|
44
|
+
else Unsupported
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
private_class_method :handler_class_for
|
|
48
|
+
|
|
49
|
+
class Base
|
|
50
|
+
def initialize(attachable, file_supported:)
|
|
51
|
+
@attachable = attachable
|
|
52
|
+
@file_supported = file_supported
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content_type
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filename
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read(max_byte_size: nil)
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rewind
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def with_media_path(_tempfile)
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def raise_unsupported
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"Could not find or build blob: expected attachable, " \
|
|
79
|
+
"got #{@attachable.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_from(io, max_byte_size)
|
|
83
|
+
max_byte_size ? io.read(max_byte_size) : io.read
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def copy_to_tempfile(tempfile, source)
|
|
87
|
+
if source.is_a?(ActiveStorage::Blob)
|
|
88
|
+
source.download { |chunk| tempfile.write(chunk) }
|
|
89
|
+
else
|
|
90
|
+
IO.copy_stream(source, tempfile)
|
|
91
|
+
source.rewind
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tempfile.flush
|
|
95
|
+
tempfile.rewind
|
|
96
|
+
yield tempfile.path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def marcel_content_type
|
|
100
|
+
Marcel::MimeType.for(name: filename.to_s)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Blob < Base
|
|
105
|
+
def content_type
|
|
106
|
+
@attachable.content_type
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def filename
|
|
110
|
+
@attachable.filename
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read(max_byte_size: nil)
|
|
114
|
+
max_byte_size ? @attachable.download_chunk(0...max_byte_size) : @attachable.download
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def with_media_path(tempfile, &block)
|
|
118
|
+
copy_to_tempfile(tempfile, @attachable, &block)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class SignedId < Blob
|
|
123
|
+
def initialize(attachable, file_supported:)
|
|
124
|
+
super(ActiveStorage::Blob.find_signed!(attachable), file_supported: file_supported)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class Uploaded < Base
|
|
129
|
+
def content_type
|
|
130
|
+
@attachable.content_type
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def filename
|
|
134
|
+
@attachable.original_filename
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read(max_byte_size: nil)
|
|
138
|
+
read_from(@attachable, max_byte_size)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def rewind
|
|
142
|
+
@attachable.rewind
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def with_media_path(_tempfile)
|
|
146
|
+
yield @attachable.path
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class HashIo < Base
|
|
151
|
+
def content_type
|
|
152
|
+
@attachable[:content_type]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def filename
|
|
156
|
+
@attachable[:filename]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def read(max_byte_size: nil)
|
|
160
|
+
read_from(@attachable[:io], max_byte_size)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def rewind
|
|
164
|
+
@attachable[:io].rewind
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def with_media_path(tempfile, &block)
|
|
168
|
+
io = @attachable[:io]
|
|
169
|
+
if io.is_a?(StringIO)
|
|
170
|
+
copy_to_tempfile(tempfile, io, &block)
|
|
171
|
+
else
|
|
172
|
+
File.open(io) { |file| yield file.path }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class FileLike < Base
|
|
178
|
+
def initialize(attachable, file_supported:)
|
|
179
|
+
super
|
|
180
|
+
raise_unsupported unless file_supported
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def content_type
|
|
184
|
+
marcel_content_type
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def filename
|
|
188
|
+
File.basename(@attachable)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def read(max_byte_size: nil)
|
|
192
|
+
read_from(@attachable, max_byte_size)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def rewind
|
|
196
|
+
@attachable.rewind
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def with_media_path(_tempfile)
|
|
200
|
+
yield @attachable.path
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class Path < FileLike
|
|
205
|
+
def rewind
|
|
206
|
+
File.open(@attachable) { |file| file.rewind }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def with_media_path(_tempfile)
|
|
210
|
+
yield @attachable.to_s
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
class Unsupported < Base
|
|
215
|
+
def initialize(attachable, file_supported:)
|
|
216
|
+
super
|
|
217
|
+
raise_unsupported
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -24,6 +24,11 @@ module ActiveStorageValidations
|
|
|
24
24
|
].freeze
|
|
25
25
|
METADATA_KEYS = %i[content_type].freeze
|
|
26
26
|
|
|
27
|
+
# State for a single attachable check. Active Model builds one validator per
|
|
28
|
+
# class and reuses it for every record and every thread, so this must travel
|
|
29
|
+
# through arguments rather than instance variables.
|
|
30
|
+
Context = Struct.new(:authorized_content_types, :content_type, :filename)
|
|
31
|
+
|
|
27
32
|
def check_validity!
|
|
28
33
|
ensure_exactly_one_validator_option
|
|
29
34
|
ensure_content_types_validity
|
|
@@ -33,12 +38,12 @@ module ActiveStorageValidations
|
|
|
33
38
|
def validate_each(record, attribute, _value)
|
|
34
39
|
return if no_attachments?(record, attribute)
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
return if
|
|
41
|
+
authorized_content_types = authorized_content_types_from_options(record)
|
|
42
|
+
return if authorized_content_types.empty?
|
|
38
43
|
|
|
39
44
|
attachables_and_blobs(record, attribute).each do |attachable, blob|
|
|
40
|
-
|
|
41
|
-
is_valid?(record, attribute, attachable, blob)
|
|
45
|
+
context = Context.new(authorized_content_types, blob.content_type, blob.filename.to_s)
|
|
46
|
+
is_valid?(record, attribute, attachable, blob, context)
|
|
42
47
|
end
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -55,76 +60,47 @@ module ActiveStorageValidations
|
|
|
55
60
|
end
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
def set_attachable_cached_values(blob)
|
|
59
|
-
@attachable_content_type = blob.content_type
|
|
60
|
-
@attachable_filename = blob.filename.to_s
|
|
61
|
-
end
|
|
62
|
-
|
|
63
63
|
# Check if the provided content_type is authorized and not spoofed against
|
|
64
64
|
# the file io.
|
|
65
|
-
def is_valid?(record, attribute, attachable, blob)
|
|
66
|
-
authorized_content_type?(record, attribute, attachable) &&
|
|
67
|
-
not_spoofing_content_type?(record, attribute, attachable, blob)
|
|
65
|
+
def is_valid?(record, attribute, attachable, blob, context)
|
|
66
|
+
authorized_content_type?(record, attribute, attachable, context) &&
|
|
67
|
+
not_spoofing_content_type?(record, attribute, attachable, blob, context)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
# We do not perform any validations against the extension because it is an
|
|
74
|
-
# unreliable source of truth. For example, a `.csv` file could have its
|
|
75
|
-
# `text/csv` content_type changed to `application/vnd.ms-excel` because
|
|
76
|
-
# it had been opened by Excel at some point, making the file extension vs
|
|
77
|
-
# file content_type check invalid.
|
|
78
|
-
# def extension_matches_content_type?(record, attribute, attachable)
|
|
79
|
-
# return true if !@attachable_filename || !@attachable_content_type
|
|
80
|
-
|
|
81
|
-
# extension = @attachable_filename.split('.').last
|
|
82
|
-
# possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
|
|
83
|
-
# return true if possible_extensions && extension.downcase.in?(possible_extensions)
|
|
84
|
-
|
|
85
|
-
# errors_options = initialize_and_populate_error_options(options, attachable)
|
|
86
|
-
# add_error(record, attribute, ERROR_TYPES.first, **errors_options)
|
|
87
|
-
# false
|
|
88
|
-
# end
|
|
89
|
-
|
|
90
|
-
def authorized_content_type?(record, attribute, attachable)
|
|
91
|
-
attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
|
|
70
|
+
def authorized_content_type?(record, attribute, attachable, context)
|
|
71
|
+
attachable_content_type_is_authorized = context.authorized_content_types.any? do |authorized_content_type|
|
|
92
72
|
case authorized_content_type
|
|
93
|
-
when String then authorized_content_type == marcel_attachable_content_type(
|
|
94
|
-
when Regexp then authorized_content_type.match?(marcel_attachable_content_type(
|
|
73
|
+
when String then authorized_content_type == marcel_attachable_content_type(context)
|
|
74
|
+
when Regexp then authorized_content_type.match?(marcel_attachable_content_type(context).to_s)
|
|
95
75
|
end
|
|
96
76
|
end
|
|
97
77
|
|
|
98
78
|
return true if attachable_content_type_is_authorized
|
|
99
79
|
|
|
100
|
-
add_content_type_invalid_error(record, attribute, attachable)
|
|
80
|
+
add_content_type_invalid_error(record, attribute, attachable, context)
|
|
101
81
|
end
|
|
102
82
|
|
|
103
|
-
def marcel_attachable_content_type(
|
|
104
|
-
Marcel::MimeType.for(declared_type:
|
|
83
|
+
def marcel_attachable_content_type(context)
|
|
84
|
+
Marcel::MimeType.for(declared_type: context.content_type, name: context.filename)
|
|
105
85
|
end
|
|
106
86
|
|
|
107
|
-
def not_spoofing_content_type?(record, attribute, attachable, blob)
|
|
87
|
+
def not_spoofing_content_type?(record, attribute, attachable, blob, context)
|
|
108
88
|
return true unless enable_spoofing_protection?
|
|
109
89
|
|
|
110
|
-
|
|
90
|
+
detected_content_type = begin
|
|
111
91
|
metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
|
|
112
92
|
rescue ActiveStorage::FileNotFoundError
|
|
113
93
|
add_attachment_missing_error(record, attribute, attachable)
|
|
114
94
|
return false
|
|
115
95
|
end
|
|
116
96
|
|
|
117
|
-
if
|
|
118
|
-
add_content_type_spoofed_error(record, attribute, attachable,
|
|
97
|
+
if content_type_mismatch?(context.content_type, detected_content_type)
|
|
98
|
+
add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
|
|
119
99
|
else
|
|
120
100
|
true
|
|
121
101
|
end
|
|
122
102
|
end
|
|
123
103
|
|
|
124
|
-
def disable_spoofing_protection?
|
|
125
|
-
!enable_spoofing_protection?
|
|
126
|
-
end
|
|
127
|
-
|
|
128
104
|
def enable_spoofing_protection?
|
|
129
105
|
spoofing_protection_backend.present?
|
|
130
106
|
end
|
|
@@ -147,14 +123,14 @@ module ActiveStorageValidations
|
|
|
147
123
|
ERROR_MESSAGE
|
|
148
124
|
end
|
|
149
125
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
!
|
|
126
|
+
def content_type_mismatch?(attachable_content_type, detected_content_type)
|
|
127
|
+
attachable_content_type.present? &&
|
|
128
|
+
!content_types_intersect?(attachable_content_type, detected_content_type)
|
|
153
129
|
end
|
|
154
130
|
|
|
155
|
-
def
|
|
156
|
-
enlarged_content_type(content_type_without_parameters(
|
|
157
|
-
enlarged_content_type(content_type_without_parameters(
|
|
131
|
+
def content_types_intersect?(attachable_content_type, detected_content_type)
|
|
132
|
+
enlarged_content_type(content_type_without_parameters(attachable_content_type)).intersect?(
|
|
133
|
+
enlarged_content_type(content_type_without_parameters(detected_content_type))
|
|
158
134
|
)
|
|
159
135
|
end
|
|
160
136
|
|
|
@@ -166,26 +142,26 @@ module ActiveStorageValidations
|
|
|
166
142
|
Marcel::TYPE_PARENTS[content_type] || []
|
|
167
143
|
end
|
|
168
144
|
|
|
169
|
-
def add_content_type_invalid_error(record, attribute, attachable)
|
|
170
|
-
errors_options = initialize_and_populate_error_options(options, attachable)
|
|
145
|
+
def add_content_type_invalid_error(record, attribute, attachable, context)
|
|
146
|
+
errors_options = initialize_and_populate_error_options(options, attachable, context)
|
|
171
147
|
add_error(record, attribute, ERROR_TYPES.first, **errors_options)
|
|
172
148
|
false
|
|
173
149
|
end
|
|
174
150
|
|
|
175
|
-
def add_content_type_spoofed_error(record, attribute, attachable, detected_content_type)
|
|
176
|
-
errors_options = initialize_and_populate_error_options(options, attachable)
|
|
177
|
-
errors_options[:detected_content_type] =
|
|
178
|
-
errors_options[:detected_human_content_type] = content_type_to_human_format(
|
|
151
|
+
def add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
|
|
152
|
+
errors_options = initialize_and_populate_error_options(options, attachable, context)
|
|
153
|
+
errors_options[:detected_content_type] = detected_content_type
|
|
154
|
+
errors_options[:detected_human_content_type] = content_type_to_human_format(detected_content_type)
|
|
179
155
|
add_error(record, attribute, ERROR_TYPES.second, **errors_options)
|
|
180
156
|
false
|
|
181
157
|
end
|
|
182
158
|
|
|
183
|
-
def initialize_and_populate_error_options(options, attachable)
|
|
159
|
+
def initialize_and_populate_error_options(options, attachable, context)
|
|
184
160
|
errors_options = initialize_error_options(options, attachable)
|
|
185
|
-
errors_options[:content_type] =
|
|
186
|
-
errors_options[:human_content_type] = content_type_to_human_format(
|
|
187
|
-
errors_options[:authorized_human_content_types] = content_type_to_human_format(
|
|
188
|
-
errors_options[:count] =
|
|
161
|
+
errors_options[:content_type] = context.content_type
|
|
162
|
+
errors_options[:human_content_type] = content_type_to_human_format(context.content_type)
|
|
163
|
+
errors_options[:authorized_human_content_types] = content_type_to_human_format(context.authorized_content_types)
|
|
164
|
+
errors_options[:count] = context.authorized_content_types.size
|
|
189
165
|
errors_options
|
|
190
166
|
end
|
|
191
167
|
|
|
@@ -30,7 +30,9 @@ module ActiveStorageValidations
|
|
|
30
30
|
next
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# to_f, not to_i: durations are floats, and truncating would treat any
|
|
34
|
+
# file shorter than a second as unanalyzable.
|
|
35
|
+
if duration.to_f <= 0
|
|
34
36
|
add_media_metadata_missing_error(record, attribute, attachable)
|
|
35
37
|
next
|
|
36
38
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActiveStorageValidations
|
|
4
|
+
# Registers the gem as a Rails engine so `config/locales/*.yml` is added to
|
|
5
|
+
# the I18n load path. Initializers live on {Railtie} (Active Record include,
|
|
6
|
+
# FormBuilder prepend, Blob metadata).
|
|
4
7
|
class Engine < ::Rails::Engine
|
|
5
8
|
end
|
|
6
9
|
end
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "marcel"
|
|
4
4
|
|
|
5
|
-
Marcel::MimeType.extend "application/x-rar-compressed", parents: %
|
|
6
|
-
Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %
|
|
7
|
-
Marcel::MimeType.extend "audio/x-m4a", parents: %
|
|
8
|
-
Marcel::MimeType.extend "text/xml", parents: %
|
|
9
|
-
Marcel::MimeType.extend "video/theora", parents: %
|
|
5
|
+
Marcel::MimeType.extend "application/x-rar-compressed", parents: %w[application/x-rar]
|
|
6
|
+
Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %w[audio/x-aac]
|
|
7
|
+
Marcel::MimeType.extend "audio/x-m4a", parents: %w[audio/mp4]
|
|
8
|
+
Marcel::MimeType.extend "text/xml", parents: %w[application/xml] # alias
|
|
9
|
+
Marcel::MimeType.extend "video/theora", parents: %w[video/ogg]
|
|
10
10
|
|
|
11
11
|
# Add empty content type
|
|
12
12
|
Marcel::MimeType.extend "inode/x-empty", extensions: %w[empty]
|
|
@@ -33,7 +33,7 @@ module ActiveStorageValidations
|
|
|
33
33
|
initialize_messageable
|
|
34
34
|
initialize_rspecable
|
|
35
35
|
@attribute_name = attribute_name
|
|
36
|
-
@min = @max = nil
|
|
36
|
+
@min = @max = @exact = nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def less_than(value)
|
|
@@ -107,12 +107,15 @@ module ActiveStorageValidations
|
|
|
107
107
|
@min.nil? || !passes_validation_with_value(@min - 1)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Probe the inclusive bound itself. Checking only min+1 / max-1 lets
|
|
111
|
+
# less_than(n) satisfy less_than_or_equal_to(n) (and greater_than
|
|
112
|
+
# satisfy greater_than_or_equal_to).
|
|
110
113
|
def higher_than_min?
|
|
111
|
-
@min.nil? || passes_validation_with_value(@min
|
|
114
|
+
@min.nil? || passes_validation_with_value(@min)
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
def lower_than_max?
|
|
115
|
-
@max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max
|
|
118
|
+
@max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max)
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
def not_higher_than_max?
|
|
@@ -120,7 +123,11 @@ module ActiveStorageValidations
|
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
def equal_to_exact?
|
|
123
|
-
@exact.nil?
|
|
126
|
+
return true if @exact.nil?
|
|
127
|
+
|
|
128
|
+
passes_validation_with_value(@exact) &&
|
|
129
|
+
!passes_validation_with_value(@exact - smallest_measurement) &&
|
|
130
|
+
!passes_validation_with_value(@exact + smallest_measurement)
|
|
124
131
|
end
|
|
125
132
|
|
|
126
133
|
def smallest_measurement
|
|
@@ -61,6 +61,7 @@ module ActiveStorageValidations
|
|
|
61
61
|
is_a_valid_active_storage_attribute? &&
|
|
62
62
|
is_context_valid? &&
|
|
63
63
|
is_except_on_valid? &&
|
|
64
|
+
is_allowing_blank? &&
|
|
64
65
|
is_custom_message_valid? &&
|
|
65
66
|
file_count_not_smaller_than_min? &&
|
|
66
67
|
file_count_equal_min? &&
|
|
@@ -83,7 +84,7 @@ module ActiveStorageValidations
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def file_count_not_smaller_than_min?
|
|
86
|
-
@min.nil? || @min.zero? || !passes_validation_with_limits(@min - 1)
|
|
87
|
+
@min.nil? || @min.zero? || (@allow_blank && @min == 1) || !passes_validation_with_limits(@min - 1)
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def file_count_equal_min?
|
|
@@ -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,25 +7,37 @@ 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"
|
|
13
14
|
|
|
14
15
|
module ActiveStorageValidations
|
|
15
16
|
module Matchers
|
|
16
|
-
#
|
|
17
|
+
# Temporary singleton-method wrap. Independent of RSpec::Mocks and of
|
|
18
|
+
# Minitest::Mock / Object#stub (extracted to minitest-mock in Minitest 6),
|
|
19
|
+
# so size / metadata matchers work on stock Minitest 5 and 6.
|
|
17
20
|
def self.stub_method(object, method, result)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
singleton = object.singleton_class
|
|
22
|
+
owned = singleton.instance_methods(false).include?(method)
|
|
23
|
+
original = owned ? singleton.instance_method(method) : nil
|
|
24
|
+
|
|
25
|
+
singleton.define_method(method) { |*_args, **_kwargs| result }
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
restore_stubbed_method(singleton, method, owned, original)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.restore_stubbed_method(singleton, method, owned, original)
|
|
32
|
+
return unless singleton
|
|
33
|
+
|
|
34
|
+
if owned
|
|
35
|
+
singleton.define_method(method, original)
|
|
36
|
+
elsif singleton.instance_methods(false).include?(method)
|
|
37
|
+
singleton.remove_method(method)
|
|
27
38
|
end
|
|
28
39
|
end
|
|
40
|
+
private_class_method :restore_stubbed_method
|
|
29
41
|
|
|
30
42
|
def self.mock_metadata(attachment, metadata = {})
|
|
31
43
|
asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
|