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.
Files changed (40) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +3 -0
  3. data/.rubocop.yml +32 -0
  4. data/.tool-versions +1 -0
  5. data/.travis.yml +33 -5
  6. data/Appraisals +16 -10
  7. data/CHANGELOG.md +26 -0
  8. data/Gemfile +2 -0
  9. data/README.md +32 -21
  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.1.gemfile +1 -0
  15. data/gemfiles/activemodel_4.2.gemfile +2 -1
  16. data/gemfiles/{activemodel_3.0.gemfile → activemodel_5.0.gemfile} +1 -1
  17. data/gemfiles/{activemodel_3.1.gemfile → activemodel_5.2.gemfile} +1 -1
  18. data/lib/file_validators.rb +11 -2
  19. data/lib/file_validators/error.rb +6 -0
  20. data/lib/file_validators/locale/en.yml +0 -2
  21. data/lib/file_validators/mime_type_analyzer.rb +106 -0
  22. data/lib/file_validators/validators/file_content_type_validator.rb +37 -42
  23. data/lib/file_validators/validators/file_size_validator.rb +62 -19
  24. data/lib/file_validators/version.rb +3 -1
  25. data/spec/integration/combined_validators_integration_spec.rb +3 -1
  26. data/spec/integration/file_content_type_validation_integration_spec.rb +117 -11
  27. data/spec/integration/file_size_validator_integration_spec.rb +100 -10
  28. data/spec/lib/file_validators/mime_type_analyzer_spec.rb +139 -0
  29. data/spec/lib/file_validators/validators/file_content_type_validator_spec.rb +93 -36
  30. data/spec/lib/file_validators/validators/file_size_validator_spec.rb +87 -34
  31. data/spec/spec_helper.rb +9 -1
  32. data/spec/support/fakeio.rb +17 -0
  33. data/spec/support/helpers.rb +7 -0
  34. data/spec/support/matchers/allow_content_type.rb +2 -0
  35. data/spec/support/matchers/allow_file_size.rb +2 -0
  36. metadata +87 -27
  37. data/lib/file_validators/utils/content_type_detector.rb +0 -46
  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,25 +1,33 @@
1
- require 'file_validators/utils/content_type_detector'
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 = [:allow, :exclude].freeze
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
- value = JSON.parse(value) if value.is_a?(String) && value.present?
16
- unless value.blank?
17
- mode = option_value(record, :mode)
18
- content_type = get_content_type(value, mode)
19
- allowed_types = option_content_types(record, :allow)
20
- forbidden_types = option_content_types(record, :exclude)
21
-
22
- validate_media_type(record, attribute, content_type, get_file_name(value)) if mode == :strict
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 get_file_path(value)
43
- if value.try(:path)
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
- def get_file_name(value)
51
- if value.try(:original_filename)
52
- value.original_filename
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, mode)
59
- case mode
60
- when :strict
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? and allowed_types.none? { |type| type === content_type }
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
- 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
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
- 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.0.2'
4
+ VERSION = '3.0.0.beta2'
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