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.
Files changed (40) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +32 -0
  4. data/.tool-versions +1 -0
  5. data/.travis.yml +43 -7
  6. data/Appraisals +13 -13
  7. data/CHANGELOG.md +31 -0
  8. data/Gemfile +2 -0
  9. data/README.md +26 -18
  10. data/Rakefile +3 -1
  11. data/file_validators.gemspec +13 -7
  12. data/gemfiles/activemodel_3.2.gemfile +2 -1
  13. data/gemfiles/activemodel_4.0.gemfile +2 -1
  14. data/gemfiles/{activemodel_4.2.gemfile → activemodel_5.0.gemfile} +1 -1
  15. data/gemfiles/{activemodel_4.1.gemfile → activemodel_6.0.gemfile} +1 -1
  16. data/gemfiles/{activemodel_3.0.gemfile → activemodel_6.1.gemfile} +1 -1
  17. data/lib/file_validators.rb +6 -7
  18. data/lib/file_validators/error.rb +6 -0
  19. data/lib/file_validators/mime_type_analyzer.rb +106 -0
  20. data/lib/file_validators/validators/file_content_type_validator.rb +37 -33
  21. data/lib/file_validators/validators/file_size_validator.rb +62 -19
  22. data/lib/file_validators/version.rb +3 -1
  23. data/spec/integration/combined_validators_integration_spec.rb +3 -1
  24. data/spec/integration/file_content_type_validation_integration_spec.rb +117 -11
  25. data/spec/integration/file_size_validator_integration_spec.rb +100 -10
  26. data/spec/lib/file_validators/mime_type_analyzer_spec.rb +139 -0
  27. data/spec/lib/file_validators/validators/file_content_type_validator_spec.rb +90 -32
  28. data/spec/lib/file_validators/validators/file_size_validator_spec.rb +84 -30
  29. data/spec/spec_helper.rb +5 -0
  30. data/spec/support/fakeio.rb +17 -0
  31. data/spec/support/helpers.rb +7 -0
  32. data/spec/support/matchers/allow_content_type.rb +2 -0
  33. data/spec/support/matchers/allow_file_size.rb +2 -0
  34. metadata +86 -28
  35. data/CHANGELOG.mod +0 -6
  36. data/gemfiles/activemodel_3.1.gemfile +0 -8
  37. data/lib/file_validators/utils/content_type_detector.rb +0 -64
  38. data/lib/file_validators/utils/media_type_spoof_detector.rb +0 -46
  39. data/spec/lib/file_validators/utils/content_type_detector_spec.rb +0 -27
  40. 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 = [:allow, :exclude].freeze
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
- value = JSON.parse(value) if value.is_a?(String) && value.present?
13
- unless value.blank?
14
- mode = option_value(record, :mode)
15
- content_type = get_content_type(value, mode)
16
- allowed_types = option_content_types(record, :allow)
17
- forbidden_types = option_content_types(record, :exclude)
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 get_file_path(value)
39
- if value.try(:path)
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
- def get_file_name(value)
47
- if value.try(:original_filename)
48
- value.original_filename
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, mode)
55
- case mode
56
- when :strict
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? and allowed_types.none? { |type| type === content_type }
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
- record.errors.add attribute, error, options.merge(types: option_types.join(', '))
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
- value = JSON.parse(value) if value.is_a?(String) && value.present?
17
- unless value.blank?
18
- options.slice(*CHECKS.keys).each do |option, option_value|
19
- value = OpenStruct.new(value) if value.is_a?(Hash)
20
- option_value = option_value.call(record) if option_value.is_a?(Proc)
21
- unless valid_size?(value.size, option, option_value)
22
- record.errors.add(attribute,
23
- "file_size_is_#{option}".to_sym,
24
- filtered_options(value).merge!(detect_error_options(option_value)))
25
- end
26
- end
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 - :in, :less_than,
33
- :less_than_or_equal_to, :greater_than and :greater_than_or_equal_to'
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(:'number.human.storage_units.format', :locale => options[:locale], :raise => true)
77
- unit = I18n.translate(:'number.human.storage_units.units.byte', :locale => options[:locale], :count => size.to_i, :raise => true)
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
  module FileValidators
2
- VERSION = '2.1.0'
4
+ VERSION = '3.0.0'
3
5
  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, { less_than: 20.kilobytes }
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'], mode: :strict }
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: lambda { |record| ['image/jpeg', 'text/plain'] },
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'], mode: :strict }
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: lambda { |record| /^image\/.*/ }, mode: :strict }
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', mode: :strict }
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 { subject.avatar = "{\"filename\":\"img140910_88338.GIF\",\"content_type\":\"image/gif\",\"size\":13150}" }
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 { subject.avatar = "{\"filename\":\"img140910_88338.jpg\",\"content_type\":\"image/jpeg\",\"size\":13150}" }
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 { subject.avatar = { "filename" => "img140910_88338.GIF", "content_type" => "image/gif", "size" => 13150 } }
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 { subject.avatar = { "filename" => "img140910_88338.jpg", "content_type" => "image/jpeg", "size" => 13150 } }
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