file_validators 2.1.0 → 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +2 -0
- data/.rubocop.yml +32 -0
- data/.tool-versions +1 -0
- data/.travis.yml +43 -7
- data/Appraisals +13 -13
- data/CHANGELOG.md +31 -0
- data/Gemfile +2 -0
- data/README.md +26 -18
- data/Rakefile +3 -1
- data/file_validators.gemspec +13 -7
- data/gemfiles/activemodel_3.2.gemfile +2 -1
- data/gemfiles/activemodel_4.0.gemfile +2 -1
- data/gemfiles/{activemodel_4.2.gemfile → activemodel_5.0.gemfile} +1 -1
- data/gemfiles/{activemodel_4.1.gemfile → activemodel_6.0.gemfile} +1 -1
- data/gemfiles/{activemodel_3.0.gemfile → activemodel_6.1.gemfile} +1 -1
- data/lib/file_validators.rb +6 -7
- data/lib/file_validators/error.rb +6 -0
- data/lib/file_validators/mime_type_analyzer.rb +106 -0
- data/lib/file_validators/validators/file_content_type_validator.rb +37 -33
- data/lib/file_validators/validators/file_size_validator.rb +62 -19
- data/lib/file_validators/version.rb +3 -1
- data/spec/integration/combined_validators_integration_spec.rb +3 -1
- data/spec/integration/file_content_type_validation_integration_spec.rb +117 -11
- data/spec/integration/file_size_validator_integration_spec.rb +100 -10
- data/spec/lib/file_validators/mime_type_analyzer_spec.rb +139 -0
- data/spec/lib/file_validators/validators/file_content_type_validator_spec.rb +90 -32
- data/spec/lib/file_validators/validators/file_size_validator_spec.rb +84 -30
- data/spec/spec_helper.rb +5 -0
- data/spec/support/fakeio.rb +17 -0
- data/spec/support/helpers.rb +7 -0
- data/spec/support/matchers/allow_content_type.rb +2 -0
- data/spec/support/matchers/allow_file_size.rb +2 -0
- metadata +86 -28
- data/CHANGELOG.mod +0 -6
- data/gemfiles/activemodel_3.1.gemfile +0 -8
- data/lib/file_validators/utils/content_type_detector.rb +0 -64
- data/lib/file_validators/utils/media_type_spoof_detector.rb +0 -46
- data/spec/lib/file_validators/utils/content_type_detector_spec.rb +0 -27
- data/spec/lib/file_validators/utils/media_type_spoof_detector_spec.rb +0 -31
@@ -1,21 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActiveModel
|
2
4
|
module Validations
|
3
|
-
|
4
5
|
class FileContentTypeValidator < ActiveModel::EachValidator
|
5
|
-
CHECKS = [
|
6
|
+
CHECKS = %i[allow exclude].freeze
|
7
|
+
SUPPORTED_MODES = { relaxed: :mime_types, strict: :file }.freeze
|
6
8
|
|
7
9
|
def self.helper_method_name
|
8
10
|
:validates_file_content_type
|
9
11
|
end
|
10
12
|
|
11
13
|
def validate_each(record, attribute, value)
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
14
|
+
begin
|
15
|
+
values = parse_values(value)
|
16
|
+
rescue JSON::ParserError
|
17
|
+
record.errors.add attribute, :invalid
|
18
|
+
return
|
19
|
+
end
|
20
|
+
|
21
|
+
return if values.empty?
|
18
22
|
|
23
|
+
mode = option_value(record, :mode)
|
24
|
+
tool = option_value(record, :tool) || SUPPORTED_MODES[mode]
|
25
|
+
|
26
|
+
allowed_types = option_content_types(record, :allow)
|
27
|
+
forbidden_types = option_content_types(record, :exclude)
|
28
|
+
|
29
|
+
values.each do |val|
|
30
|
+
content_type = get_content_type(val, tool)
|
19
31
|
validate_whitelist(record, attribute, content_type, allowed_types)
|
20
32
|
validate_blacklist(record, attribute, content_type, forbidden_types)
|
21
33
|
end
|
@@ -35,31 +47,17 @@ module ActiveModel
|
|
35
47
|
|
36
48
|
private
|
37
49
|
|
38
|
-
def
|
39
|
-
|
40
|
-
value.path
|
41
|
-
else
|
42
|
-
raise ArgumentError, 'value must return a file path in order to validate file content type'
|
43
|
-
end
|
44
|
-
end
|
50
|
+
def parse_values(value)
|
51
|
+
return [] unless value.present?
|
45
52
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
else
|
50
|
-
File.basename(get_file_path(value))
|
51
|
-
end
|
53
|
+
value = JSON.parse(value) if value.is_a?(String)
|
54
|
+
|
55
|
+
Array.wrap(value).reject(&:blank?)
|
52
56
|
end
|
53
57
|
|
54
|
-
def get_content_type(value,
|
55
|
-
|
56
|
-
|
57
|
-
file_path = get_file_path(value)
|
58
|
-
file_name = get_file_name(value)
|
59
|
-
FileValidators::Utils::ContentTypeDetector.new(file_path, file_name).detect
|
60
|
-
when :relaxed
|
61
|
-
file_name = get_file_name(value)
|
62
|
-
MIME::Types.type_for(file_name).first
|
58
|
+
def get_content_type(value, tool)
|
59
|
+
if tool.present?
|
60
|
+
FileValidators::MimeTypeAnalyzer.new(tool).call(value)
|
63
61
|
else
|
64
62
|
value = OpenStruct.new(value) if value.is_a?(Hash)
|
65
63
|
value.content_type
|
@@ -75,7 +73,7 @@ module ActiveModel
|
|
75
73
|
end
|
76
74
|
|
77
75
|
def validate_whitelist(record, attribute, content_type, allowed_types)
|
78
|
-
if allowed_types.present?
|
76
|
+
if allowed_types.present? && allowed_types.none? { |type| type === content_type }
|
79
77
|
mark_invalid record, attribute, :allowed_file_content_types, allowed_types
|
80
78
|
end
|
81
79
|
end
|
@@ -87,7 +85,10 @@ module ActiveModel
|
|
87
85
|
end
|
88
86
|
|
89
87
|
def mark_invalid(record, attribute, error, option_types)
|
90
|
-
|
88
|
+
error_options = options.merge(types: option_types.join(', '))
|
89
|
+
unless record.errors.added?(attribute, error, error_options)
|
90
|
+
record.errors.add attribute, error, **error_options
|
91
|
+
end
|
91
92
|
end
|
92
93
|
end
|
93
94
|
|
@@ -110,6 +111,10 @@ module ActiveModel
|
|
110
111
|
# :relaxed validates the content type based on the file name using
|
111
112
|
# the mime-types gem. It's only for sanity check.
|
112
113
|
# If mode is not set then it uses form supplied content type.
|
114
|
+
# * +tool+: :file, :fastimage, :filemagic, :mimemagic, :marcel, :mime_types, :mini_mime
|
115
|
+
# You can choose a different built-in MIME type analyzer
|
116
|
+
# By default supplied content type is used to determine the MIME type
|
117
|
+
# This option have precedence over mode option
|
113
118
|
# * +if+: A lambda or name of an instance method. Validation will only
|
114
119
|
# be run is this lambda or method returns true.
|
115
120
|
# * +unless+: Same as +if+ but validates if lambda or method returns false.
|
@@ -117,6 +122,5 @@ module ActiveModel
|
|
117
122
|
validates_with FileContentTypeValidator, _merge_attributes(attr_names)
|
118
123
|
end
|
119
124
|
end
|
120
|
-
|
121
125
|
end
|
122
126
|
end
|
@@ -1,6 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActiveModel
|
2
4
|
module Validations
|
3
|
-
|
4
5
|
class FileSizeValidator < ActiveModel::EachValidator
|
5
6
|
CHECKS = { in: :===,
|
6
7
|
less_than: :<,
|
@@ -13,24 +14,25 @@ module ActiveModel
|
|
13
14
|
end
|
14
15
|
|
15
16
|
def validate_each(record, attribute, value)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
17
|
+
begin
|
18
|
+
values = parse_values(value)
|
19
|
+
rescue JSON::ParserError
|
20
|
+
record.errors.add attribute, :invalid
|
21
|
+
return
|
22
|
+
end
|
23
|
+
|
24
|
+
return if values.empty?
|
25
|
+
|
26
|
+
options.slice(*CHECKS.keys).each do |option, option_value|
|
27
|
+
check_errors(record, attribute, values, option, option_value)
|
27
28
|
end
|
28
29
|
end
|
29
30
|
|
30
31
|
def check_validity!
|
31
32
|
unless (CHECKS.keys & options.keys).present?
|
32
|
-
raise ArgumentError, 'You must at least pass in one of these options
|
33
|
-
|
33
|
+
raise ArgumentError, 'You must at least pass in one of these options' \
|
34
|
+
' - :in, :less_than, :less_than_or_equal_to,' \
|
35
|
+
' :greater_than and :greater_than_or_equal_to'
|
34
36
|
end
|
35
37
|
|
36
38
|
check_options(Numeric, options.slice(*(CHECKS.keys - [:in])))
|
@@ -39,6 +41,17 @@ module ActiveModel
|
|
39
41
|
|
40
42
|
private
|
41
43
|
|
44
|
+
def parse_values(value)
|
45
|
+
return [] unless value.present?
|
46
|
+
|
47
|
+
value = JSON.parse(value) if value.is_a?(String)
|
48
|
+
return [] unless value.present?
|
49
|
+
|
50
|
+
value = OpenStruct.new(value) if value.is_a?(Hash)
|
51
|
+
|
52
|
+
Array.wrap(value).reject(&:blank?)
|
53
|
+
end
|
54
|
+
|
42
55
|
def check_options(klass, options)
|
43
56
|
options.each do |option, value|
|
44
57
|
unless value.is_a?(klass) || value.is_a?(Proc)
|
@@ -47,7 +60,28 @@ module ActiveModel
|
|
47
60
|
end
|
48
61
|
end
|
49
62
|
|
63
|
+
def check_errors(record, attribute, values, option, option_value)
|
64
|
+
option_value = option_value.call(record) if option_value.is_a?(Proc)
|
65
|
+
has_invalid_size = values.any? { |v| !valid_size?(value_byte_size(v), option, option_value) }
|
66
|
+
if has_invalid_size
|
67
|
+
record.errors.add(
|
68
|
+
attribute,
|
69
|
+
"file_size_is_#{option}".to_sym,
|
70
|
+
**filtered_options(values).merge!(detect_error_options(option_value))
|
71
|
+
)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def value_byte_size(value)
|
76
|
+
if value.respond_to?(:byte_size)
|
77
|
+
value.byte_size
|
78
|
+
else
|
79
|
+
value.size
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
50
83
|
def valid_size?(size, option, option_value)
|
84
|
+
return false if size.nil?
|
51
85
|
if option_value.is_a?(Range)
|
52
86
|
option_value.send(CHECKS[option], size)
|
53
87
|
else
|
@@ -63,7 +97,7 @@ module ActiveModel
|
|
63
97
|
|
64
98
|
def detect_error_options(option_value)
|
65
99
|
if option_value.is_a?(Range)
|
66
|
-
{ min: human_size(option_value.min), max: human_size(option_value.max)
|
100
|
+
{ min: human_size(option_value.min), max: human_size(option_value.max) }
|
67
101
|
else
|
68
102
|
{ count: human_size(option_value) }
|
69
103
|
end
|
@@ -73,12 +107,22 @@ module ActiveModel
|
|
73
107
|
if defined?(ActiveSupport::NumberHelper) # Rails 4.0+
|
74
108
|
ActiveSupport::NumberHelper.number_to_human_size(size)
|
75
109
|
else
|
76
|
-
storage_units_format = I18n.translate(
|
77
|
-
|
110
|
+
storage_units_format = I18n.translate(
|
111
|
+
:'number.human.storage_units.format',
|
112
|
+
locale: options[:locale],
|
113
|
+
raise: true
|
114
|
+
)
|
115
|
+
|
116
|
+
unit = I18n.translate(
|
117
|
+
:'number.human.storage_units.units.byte',
|
118
|
+
locale: options[:locale],
|
119
|
+
count: size.to_i,
|
120
|
+
raise: true
|
121
|
+
)
|
122
|
+
|
78
123
|
storage_units_format.gsub(/%n/, size.to_i.to_s).gsub(/%u/, unit).html_safe
|
79
124
|
end
|
80
125
|
end
|
81
|
-
|
82
126
|
end
|
83
127
|
|
84
128
|
module HelperMethods
|
@@ -97,6 +141,5 @@ module ActiveModel
|
|
97
141
|
validates_with FileSizeValidator, _merge_attributes(attr_names)
|
98
142
|
end
|
99
143
|
end
|
100
|
-
|
101
144
|
end
|
102
145
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'spec_helper'
|
2
4
|
require 'rack/test/uploaded_file'
|
3
5
|
|
@@ -52,7 +54,7 @@ describe 'Combined File Validators integration with ActiveModel' do
|
|
52
54
|
before :all do
|
53
55
|
Person.class_eval do
|
54
56
|
Person.reset_callbacks(:validate)
|
55
|
-
validates_file_size :avatar,
|
57
|
+
validates_file_size :avatar, less_than: 20.kilobytes
|
56
58
|
validates_file_content_type :avatar, allow: 'image/jpeg'
|
57
59
|
end
|
58
60
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'spec_helper'
|
2
4
|
require 'rack/test/uploaded_file'
|
3
5
|
|
@@ -69,7 +71,8 @@ describe 'File Content Type integration with ActiveModel' do
|
|
69
71
|
before :all do
|
70
72
|
Person.class_eval do
|
71
73
|
Person.reset_callbacks(:validate)
|
72
|
-
validates :avatar, file_content_type: { allow: ['image/jpeg', 'text/plain'],
|
74
|
+
validates :avatar, file_content_type: { allow: ['image/jpeg', 'text/plain'],
|
75
|
+
mode: :strict }
|
73
76
|
end
|
74
77
|
end
|
75
78
|
|
@@ -97,7 +100,7 @@ describe 'File Content Type integration with ActiveModel' do
|
|
97
100
|
before :all do
|
98
101
|
Person.class_eval do
|
99
102
|
Person.reset_callbacks(:validate)
|
100
|
-
validates :avatar, file_content_type: { allow:
|
103
|
+
validates :avatar, file_content_type: { allow: ->(_record) { ['image/jpeg', 'text/plain'] },
|
101
104
|
mode: :strict }
|
102
105
|
end
|
103
106
|
end
|
@@ -177,7 +180,8 @@ describe 'File Content Type integration with ActiveModel' do
|
|
177
180
|
before :all do
|
178
181
|
Person.class_eval do
|
179
182
|
Person.reset_callbacks(:validate)
|
180
|
-
validates :avatar, file_content_type: { exclude: ['image/jpeg', 'text/plain'],
|
183
|
+
validates :avatar, file_content_type: { exclude: ['image/jpeg', 'text/plain'],
|
184
|
+
mode: :strict }
|
181
185
|
end
|
182
186
|
end
|
183
187
|
|
@@ -205,7 +209,8 @@ describe 'File Content Type integration with ActiveModel' do
|
|
205
209
|
before :all do
|
206
210
|
Person.class_eval do
|
207
211
|
Person.reset_callbacks(:validate)
|
208
|
-
validates :avatar, file_content_type: { exclude:
|
212
|
+
validates :avatar, file_content_type: { exclude: ->(_record) { /^image\/.*/ },
|
213
|
+
mode: :strict }
|
209
214
|
end
|
210
215
|
end
|
211
216
|
|
@@ -229,7 +234,8 @@ describe 'File Content Type integration with ActiveModel' do
|
|
229
234
|
before :all do
|
230
235
|
Person.class_eval do
|
231
236
|
Person.reset_callbacks(:validate)
|
232
|
-
validates :avatar, file_content_type: { allow: /^image\/.*/, exclude: 'image/png',
|
237
|
+
validates :avatar, file_content_type: { allow: /^image\/.*/, exclude: 'image/png',
|
238
|
+
mode: :strict }
|
233
239
|
end
|
234
240
|
end
|
235
241
|
|
@@ -246,6 +252,31 @@ describe 'File Content Type integration with ActiveModel' do
|
|
246
252
|
end
|
247
253
|
end
|
248
254
|
|
255
|
+
context ':tool option' do
|
256
|
+
before :all do
|
257
|
+
Person.class_eval do
|
258
|
+
Person.reset_callbacks(:validate)
|
259
|
+
validates :avatar, file_content_type: { allow: 'image/jpeg', tool: :marcel }
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
subject { Person.new }
|
264
|
+
|
265
|
+
context 'with valid file' do
|
266
|
+
it 'validates the file' do
|
267
|
+
subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg')
|
268
|
+
expect(subject).to be_valid
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
context 'with spoofed file' do
|
273
|
+
it 'invalidates the file' do
|
274
|
+
subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg')
|
275
|
+
expect(subject).not_to be_valid
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
249
280
|
context ':mode option' do
|
250
281
|
context 'strict mode' do
|
251
282
|
before :all do
|
@@ -334,24 +365,35 @@ describe 'File Content Type integration with ActiveModel' do
|
|
334
365
|
subject { Person.new }
|
335
366
|
|
336
367
|
context 'for invalid content type' do
|
337
|
-
before
|
368
|
+
before do
|
369
|
+
subject.avatar = '{"filename":"img140910_88338.GIF","content_type":"image/gif","size":13150}'
|
370
|
+
end
|
371
|
+
|
338
372
|
it { is_expected.not_to be_valid }
|
339
373
|
end
|
340
374
|
|
341
375
|
context 'for valid content type' do
|
342
|
-
before
|
376
|
+
before do
|
377
|
+
subject.avatar = '{"filename":"img140910_88338.jpg","content_type":"image/jpeg","size":13150}'
|
378
|
+
end
|
379
|
+
|
343
380
|
it { is_expected.to be_valid }
|
344
381
|
end
|
345
382
|
|
346
383
|
context 'empty json string' do
|
347
|
-
before { subject.avatar =
|
384
|
+
before { subject.avatar = '{}' }
|
348
385
|
it { is_expected.to be_valid }
|
349
386
|
end
|
350
387
|
|
351
388
|
context 'empty string' do
|
352
|
-
before { subject.avatar =
|
389
|
+
before { subject.avatar = '' }
|
353
390
|
it { is_expected.to be_valid }
|
354
391
|
end
|
392
|
+
|
393
|
+
context 'invalid json string' do
|
394
|
+
before { subject.avatar = '{filename":"img140910_88338.jpg","content_type":"image/jpeg","size":13150}' }
|
395
|
+
it { is_expected.not_to be_valid }
|
396
|
+
end
|
355
397
|
end
|
356
398
|
|
357
399
|
context 'image data as hash' do
|
@@ -365,12 +407,26 @@ describe 'File Content Type integration with ActiveModel' do
|
|
365
407
|
subject { Person.new }
|
366
408
|
|
367
409
|
context 'for invalid content type' do
|
368
|
-
before
|
410
|
+
before do
|
411
|
+
subject.avatar = {
|
412
|
+
'filename' => 'img140910_88338.GIF',
|
413
|
+
'content_type' => 'image/gif',
|
414
|
+
'size' => 13_150
|
415
|
+
}
|
416
|
+
end
|
417
|
+
|
369
418
|
it { is_expected.not_to be_valid }
|
370
419
|
end
|
371
420
|
|
372
421
|
context 'for valid content type' do
|
373
|
-
before
|
422
|
+
before do
|
423
|
+
subject.avatar = {
|
424
|
+
'filename' => 'img140910_88338.jpg',
|
425
|
+
'content_type' => 'image/jpeg',
|
426
|
+
'size' => 13_150
|
427
|
+
}
|
428
|
+
end
|
429
|
+
|
374
430
|
it { is_expected.to be_valid }
|
375
431
|
end
|
376
432
|
|
@@ -379,4 +435,54 @@ describe 'File Content Type integration with ActiveModel' do
|
|
379
435
|
it { is_expected.to be_valid }
|
380
436
|
end
|
381
437
|
end
|
438
|
+
|
439
|
+
context 'image data as array' do
|
440
|
+
before :all do
|
441
|
+
Person.class_eval do
|
442
|
+
Person.reset_callbacks(:validate)
|
443
|
+
validates :avatar, file_content_type: { allow: 'image/jpeg' }
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
subject { Person.new }
|
448
|
+
|
449
|
+
context 'for one invalid content type' do
|
450
|
+
before do
|
451
|
+
subject.avatar = [
|
452
|
+
Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain'),
|
453
|
+
Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg')
|
454
|
+
]
|
455
|
+
end
|
456
|
+
it { is_expected.not_to be_valid }
|
457
|
+
end
|
458
|
+
|
459
|
+
context 'for two invalid content types' do
|
460
|
+
before do
|
461
|
+
subject.avatar = [
|
462
|
+
Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain'),
|
463
|
+
Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain')
|
464
|
+
]
|
465
|
+
end
|
466
|
+
|
467
|
+
it 'is invalid and adds just one error' do
|
468
|
+
expect(subject).not_to be_valid
|
469
|
+
expect(subject.errors.count).to eq 1
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
context 'for valid content type' do
|
474
|
+
before do
|
475
|
+
subject.avatar = [
|
476
|
+
Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg'),
|
477
|
+
Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg')
|
478
|
+
]
|
479
|
+
end
|
480
|
+
it { is_expected.to be_valid }
|
481
|
+
end
|
482
|
+
|
483
|
+
context 'empty array' do
|
484
|
+
before { subject.avatar = [] }
|
485
|
+
it { is_expected.to be_valid }
|
486
|
+
end
|
487
|
+
end
|
382
488
|
end
|