file_validators 2.0.2 → 3.0.0.beta2
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 +5 -5
- data/.gitignore +3 -0
- data/.rubocop.yml +32 -0
- data/.tool-versions +1 -0
- data/.travis.yml +33 -5
- data/Appraisals +16 -10
- data/CHANGELOG.md +26 -0
- data/Gemfile +2 -0
- data/README.md +32 -21
- 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.1.gemfile +1 -0
- data/gemfiles/activemodel_4.2.gemfile +2 -1
- data/gemfiles/{activemodel_3.0.gemfile → activemodel_5.0.gemfile} +1 -1
- data/gemfiles/{activemodel_3.1.gemfile → activemodel_5.2.gemfile} +1 -1
- data/lib/file_validators.rb +11 -2
- data/lib/file_validators/error.rb +6 -0
- data/lib/file_validators/locale/en.yml +0 -2
- data/lib/file_validators/mime_type_analyzer.rb +106 -0
- data/lib/file_validators/validators/file_content_type_validator.rb +37 -42
- 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 +93 -36
- data/spec/lib/file_validators/validators/file_size_validator_spec.rb +87 -34
- data/spec/spec_helper.rb +9 -1
- 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 +87 -27
- data/lib/file_validators/utils/content_type_detector.rb +0 -46
- 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,25 +1,33 @@
|
|
1
|
-
|
2
|
-
require 'file_validators/utils/media_type_spoof_detector'
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module ActiveModel
|
5
4
|
module Validations
|
6
|
-
|
7
5
|
class FileContentTypeValidator < ActiveModel::EachValidator
|
8
|
-
CHECKS = [
|
6
|
+
CHECKS = %i[allow exclude].freeze
|
7
|
+
SUPPORTED_MODES = { relaxed: :mime_types, strict: :file }.freeze
|
9
8
|
|
10
9
|
def self.helper_method_name
|
11
10
|
:validates_file_content_type
|
12
11
|
end
|
13
12
|
|
14
13
|
def validate_each(record, attribute, value)
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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?
|
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)
|
23
31
|
validate_whitelist(record, attribute, content_type, allowed_types)
|
24
32
|
validate_blacklist(record, attribute, content_type, forbidden_types)
|
25
33
|
end
|
@@ -39,30 +47,17 @@ module ActiveModel
|
|
39
47
|
|
40
48
|
private
|
41
49
|
|
42
|
-
def
|
43
|
-
|
44
|
-
value.path
|
45
|
-
else
|
46
|
-
raise ArgumentError, 'value must return a file path in order to validate file content type'
|
47
|
-
end
|
48
|
-
end
|
50
|
+
def parse_values(value)
|
51
|
+
return [] unless value.present?
|
49
52
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
else
|
54
|
-
File.basename(get_file_path(value))
|
55
|
-
end
|
53
|
+
value = JSON.parse(value) if value.is_a?(String)
|
54
|
+
|
55
|
+
Array.wrap(value).reject(&:blank?)
|
56
56
|
end
|
57
57
|
|
58
|
-
def get_content_type(value,
|
59
|
-
|
60
|
-
|
61
|
-
file_path = get_file_path(value)
|
62
|
-
FileValidators::Utils::ContentTypeDetector.new(file_path).detect
|
63
|
-
when :relaxed
|
64
|
-
file_name = get_file_name(value)
|
65
|
-
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)
|
66
61
|
else
|
67
62
|
value = OpenStruct.new(value) if value.is_a?(Hash)
|
68
63
|
value.content_type
|
@@ -77,14 +72,8 @@ module ActiveModel
|
|
77
72
|
options[key].is_a?(Proc) ? options[key].call(record) : options[key]
|
78
73
|
end
|
79
74
|
|
80
|
-
def validate_media_type(record, attribute, content_type, file_name)
|
81
|
-
if FileValidators::Utils::MediaTypeSpoofDetector.new(content_type, file_name).spoofed?
|
82
|
-
record.errors.add attribute, :spoofed_file_media_type
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
75
|
def validate_whitelist(record, attribute, content_type, allowed_types)
|
87
|
-
if allowed_types.present?
|
76
|
+
if allowed_types.present? && allowed_types.none? { |type| type === content_type }
|
88
77
|
mark_invalid record, attribute, :allowed_file_content_types, allowed_types
|
89
78
|
end
|
90
79
|
end
|
@@ -96,7 +85,10 @@ module ActiveModel
|
|
96
85
|
end
|
97
86
|
|
98
87
|
def mark_invalid(record, attribute, error, option_types)
|
99
|
-
|
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
|
100
92
|
end
|
101
93
|
end
|
102
94
|
|
@@ -119,6 +111,10 @@ module ActiveModel
|
|
119
111
|
# :relaxed validates the content type based on the file name using
|
120
112
|
# the mime-types gem. It's only for sanity check.
|
121
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
|
122
118
|
# * +if+: A lambda or name of an instance method. Validation will only
|
123
119
|
# be run is this lambda or method returns true.
|
124
120
|
# * +unless+: Same as +if+ but validates if lambda or method returns false.
|
@@ -126,6 +122,5 @@ module ActiveModel
|
|
126
122
|
validates_with FileContentTypeValidator, _merge_attributes(attr_names)
|
127
123
|
end
|
128
124
|
end
|
129
|
-
|
130
125
|
end
|
131
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
|