file_validators 2.1.0 → 3.0.0

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 +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