active_storage_validations 1.2.0 → 1.3.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -22
  3. data/config/locales/da.yml +1 -2
  4. data/config/locales/de.yml +1 -1
  5. data/config/locales/en.yml +1 -1
  6. data/config/locales/es.yml +1 -1
  7. data/config/locales/fr.yml +1 -1
  8. data/config/locales/it.yml +1 -1
  9. data/config/locales/ja.yml +1 -1
  10. data/config/locales/nl.yml +1 -1
  11. data/config/locales/pl.yml +1 -1
  12. data/config/locales/pt-BR.yml +1 -1
  13. data/config/locales/ru.yml +1 -1
  14. data/config/locales/sv.yml +1 -1
  15. data/config/locales/tr.yml +1 -1
  16. data/config/locales/uk.yml +1 -1
  17. data/config/locales/vi.yml +1 -1
  18. data/config/locales/zh-CN.yml +1 -1
  19. data/lib/active_storage_validations/aspect_ratio_validator.rb +57 -66
  20. data/lib/active_storage_validations/attached_validator.rb +5 -3
  21. data/lib/active_storage_validations/base_size_validator.rb +4 -1
  22. data/lib/active_storage_validations/concerns/active_storageable.rb +28 -0
  23. data/lib/active_storage_validations/concerns/attachable.rb +134 -0
  24. data/lib/active_storage_validations/concerns/errorable.rb +4 -4
  25. data/lib/active_storage_validations/concerns/loggable.rb +9 -0
  26. data/lib/active_storage_validations/concerns/optionable.rb +27 -0
  27. data/lib/active_storage_validations/content_type_spoof_detector.rb +94 -0
  28. data/lib/active_storage_validations/content_type_validator.rb +113 -39
  29. data/lib/active_storage_validations/dimension_validator.rb +32 -52
  30. data/lib/active_storage_validations/limit_validator.rb +8 -5
  31. data/lib/active_storage_validations/marcel_extensor.rb +5 -0
  32. data/lib/active_storage_validations/matchers/concerns/attachable.rb +27 -9
  33. data/lib/active_storage_validations/matchers/concerns/messageable.rb +1 -1
  34. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +7 -0
  35. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  36. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +1 -10
  37. data/lib/active_storage_validations/matchers.rb +4 -15
  38. data/lib/active_storage_validations/metadata.rb +22 -26
  39. data/lib/active_storage_validations/processable_image_validator.rb +17 -32
  40. data/lib/active_storage_validations/size_validator.rb +3 -7
  41. data/lib/active_storage_validations/total_size_validator.rb +4 -8
  42. data/lib/active_storage_validations/version.rb +1 -1
  43. data/lib/active_storage_validations.rb +2 -1
  44. metadata +67 -21
  45. data/lib/active_storage_validations/option_proc_unfolding.rb +0 -16
@@ -0,0 +1,134 @@
1
+ require_relative "../metadata"
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::Attachable
5
+ #
6
+ # Validator methods for analyzing attachable.
7
+ #
8
+ # An attachable is a file representation such as ActiveStorage::Blob,
9
+ # ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, Hash, String,
10
+ # File or Pathname
11
+ module Attachable
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ # Loop through the newly submitted attachables to validate them. Using
17
+ # attachables is the only way to get the attached file io that is necessary
18
+ # to perform file analyses.
19
+ def validate_changed_files_from_metadata(record, attribute)
20
+ attachables_from_changes(record, attribute).each do |attachable|
21
+ is_valid?(record, attribute, attachable, Metadata.new(attachable).metadata)
22
+ end
23
+ end
24
+
25
+ # Retrieve an array of newly submitted attachables. Some file could be passed
26
+ # several times, we just need to perform the analysis once on the file,
27
+ # therefore the use of #uniq.
28
+ def attachables_from_changes(record, attribute)
29
+ changes = record.attachment_changes[attribute.to_s]
30
+ return [] if changes.blank?
31
+
32
+ Array.wrap(
33
+ changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable
34
+ ).uniq
35
+ end
36
+
37
+ # Retrieve the full declared content_type from attachable.
38
+ def full_attachable_content_type(attachable)
39
+ case attachable
40
+ when ActiveStorage::Blob
41
+ attachable.content_type
42
+ when ActionDispatch::Http::UploadedFile
43
+ attachable.content_type
44
+ when Rack::Test::UploadedFile
45
+ attachable.content_type
46
+ when String
47
+ blob = ActiveStorage::Blob.find_signed!(attachable)
48
+ blob.content_type
49
+ when Hash
50
+ attachable[:content_type]
51
+ when File
52
+ supports_file_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
53
+ when Pathname
54
+ supports_pathname_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
55
+ else
56
+ raise_rails_like_error(attachable)
57
+ end
58
+ end
59
+
60
+ # Retrieve the declared content_type from attachable without potential mime
61
+ # type parameters (e.g. 'application/x-rar-compressed;version=5')
62
+ def attachable_content_type(attachable)
63
+ full_attachable_content_type(attachable) && full_attachable_content_type(attachable).downcase.split(/[;,\s]/, 2).first
64
+ end
65
+
66
+ # Retrieve the io from attachable.
67
+ def attachable_io(attachable)
68
+ case attachable
69
+ when ActiveStorage::Blob
70
+ attachable.download
71
+ when ActionDispatch::Http::UploadedFile
72
+ attachable.read
73
+ when Rack::Test::UploadedFile
74
+ attachable.read
75
+ when String
76
+ blob = ActiveStorage::Blob.find_signed!(attachable)
77
+ blob.download
78
+ when Hash
79
+ attachable[:io].read
80
+ when File
81
+ supports_file_attachment? ? attachable : raise_rails_like_error(attachable)
82
+ when Pathname
83
+ supports_pathname_attachment? ? attachable.read : raise_rails_like_error(attachable)
84
+ else
85
+ raise_rails_like_error(attachable)
86
+ end
87
+ end
88
+
89
+ # Retrieve the declared filename from attachable.
90
+ def attachable_filename(attachable)
91
+ case attachable
92
+ when ActiveStorage::Blob
93
+ attachable.filename
94
+ when ActionDispatch::Http::UploadedFile
95
+ attachable.original_filename
96
+ when Rack::Test::UploadedFile
97
+ attachable.original_filename
98
+ when String
99
+ blob = ActiveStorage::Blob.find_signed!(attachable)
100
+ blob.filename
101
+ when Hash
102
+ attachable[:filename]
103
+ when File
104
+ supports_file_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
105
+ when Pathname
106
+ supports_pathname_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
107
+ else
108
+ raise_rails_like_error(attachable)
109
+ end
110
+ end
111
+
112
+ # Raise the same Rails error for not-implemented file representations.
113
+ def raise_rails_like_error(attachable)
114
+ raise(
115
+ ArgumentError,
116
+ "Could not find or build blob: expected attachable, " \
117
+ "got #{attachable.inspect}"
118
+ )
119
+ end
120
+
121
+ # Check if the current Rails version supports File or Pathname attachment
122
+ #
123
+ # https://github.com/rails/rails/blob/7-1-stable/activestorage/CHANGELOG.md#rails-710rc1-september-27-2023
124
+ def supports_file_attachment?
125
+ Rails.gem_version >= Gem::Version.new('7.1.0.rc1')
126
+ end
127
+ alias :supports_pathname_attachment? :supports_file_attachment?
128
+
129
+ # Retrieve the content_type from the file name only
130
+ def marcel_content_type_from_filename(attachable)
131
+ Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
132
+ end
133
+ end
134
+ end
@@ -16,12 +16,11 @@ module ActiveStorageValidations
16
16
  end
17
17
 
18
18
  def add_error(record, attribute, error_type, **errors_options)
19
- type = errors_options[:custom_message].presence || error_type
20
- return if record.errors.added?(attribute, type)
19
+ return if record.errors.added?(attribute, error_type)
21
20
 
22
21
  # You can read https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-add
23
22
  # to better understand how Rails model errors work
24
- record.errors.add(attribute, type, **errors_options)
23
+ record.errors.add(attribute, error_type, **errors_options)
25
24
  end
26
25
 
27
26
  private
@@ -31,8 +30,9 @@ module ActiveStorageValidations
31
30
 
32
31
  case file
33
32
  when ActiveStorage::Attached, ActiveStorage::Attachment then file.blob&.filename&.to_s
33
+ when ActiveStorage::Blob then file.filename
34
34
  when Hash then file[:filename]
35
- end
35
+ end.to_s
36
36
  end
37
37
  end
38
38
  end
@@ -0,0 +1,9 @@
1
+ module ActiveStorageValidations
2
+ module Loggable
3
+ extend ActiveSupport::Concern
4
+
5
+ def logger
6
+ Rails.logger
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ module ActiveStorageValidations
2
+ # ActiveStorageValidations::Optionable
3
+ #
4
+ # Helper method to flatten the validator options.
5
+ module Optionable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def set_flat_options(record)
11
+ flatten_options(record, self.options)
12
+ end
13
+
14
+ def flatten_options(record, options, available_checks = self.class::AVAILABLE_CHECKS)
15
+ case options
16
+ when Hash
17
+ options.merge(options) do |key, value|
18
+ available_checks&.exclude?(key) ? {} : flatten_options(record, value, nil)
19
+ end
20
+ when Array
21
+ options.map { |option| flatten_options(record, option, available_checks) }
22
+ else
23
+ options.is_a?(Proc) ? options.call(record) : options
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'concerns/attachable'
4
+ require_relative 'concerns/loggable'
5
+ require 'open3'
6
+
7
+ module ActiveStorageValidations
8
+ class ContentTypeSpoofDetector
9
+ class FileCommandLineToolNotInstalledError < StandardError; end
10
+
11
+ include Attachable
12
+ include Loggable
13
+
14
+ def initialize(record, attribute, attachable)
15
+ @record = record
16
+ @attribute = attribute
17
+ @attachable = attachable
18
+ end
19
+
20
+ def spoofed?
21
+ if supplied_content_type_vs_open3_analizer_mismatch?
22
+ logger.info "Content Type Spoofing detected for file '#{filename}'. The supplied content type is '#{supplied_content_type}' but the content type discovered using open3 is '#{content_type_from_analyzer}'."
23
+ true
24
+ else
25
+ false
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def filename
32
+ @filename ||= attachable_filename(@attachable).to_s
33
+ end
34
+
35
+ def supplied_content_type
36
+ @supplied_content_type ||= attachable_content_type(@attachable)
37
+ end
38
+
39
+ def io
40
+ @io ||= attachable_io(@attachable)
41
+ end
42
+
43
+ # Return the content_type found by Open3 analysis.
44
+ #
45
+ # Using Open3 is a better alternative than Marcel (Marcel::MimeType.for(io))
46
+ # for analyzing content type solely based on the file io.
47
+ def content_type_from_analyzer
48
+ @content_type_from_analyzer ||= open3_mime_type_for_io
49
+ end
50
+
51
+ def open3_mime_type_for_io
52
+ return nil if io.blank?
53
+
54
+ Tempfile.create do |tempfile|
55
+ tempfile.binmode
56
+ tempfile.write(io)
57
+ tempfile.rewind
58
+
59
+ command = "file -b --mime-type #{tempfile.path}"
60
+ output, status = Open3.capture2(command)
61
+
62
+ if status.success?
63
+ mime_type = output.strip
64
+ return mime_type
65
+ else
66
+ raise "Error determining MIME type: #{output}"
67
+ end
68
+
69
+ rescue Errno::ENOENT
70
+ raise FileCommandLineToolNotInstalledError, 'file command-line tool is not installed'
71
+ end
72
+ end
73
+
74
+ def supplied_content_type_vs_open3_analizer_mismatch?
75
+ supplied_content_type.present? &&
76
+ !supplied_content_type_intersects_content_type_from_analyzer?
77
+ end
78
+
79
+ def supplied_content_type_intersects_content_type_from_analyzer?
80
+ # Ruby intersects? method is only available from 3.1
81
+ enlarged_content_type(supplied_content_type).any? do |item|
82
+ enlarged_content_type(content_type_from_analyzer).include?(item)
83
+ end
84
+ end
85
+
86
+ def enlarged_content_type(content_type)
87
+ [content_type, *parent_content_types(content_type)].compact.uniq
88
+ end
89
+
90
+ def parent_content_types(content_type)
91
+ Marcel::TYPE_PARENTS[content_type] || []
92
+ end
93
+ end
94
+ end
@@ -1,16 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
4
+ require_relative 'concerns/attachable.rb'
3
5
  require_relative 'concerns/errorable.rb'
6
+ require_relative 'concerns/optionable.rb'
4
7
  require_relative 'concerns/symbolizable.rb'
8
+ require_relative 'content_type_spoof_detector.rb'
5
9
 
6
10
  module ActiveStorageValidations
7
11
  class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
8
- include OptionProcUnfolding
12
+ include ActiveStorageable
13
+ include Attachable
9
14
  include Errorable
15
+ include Optionable
10
16
  include Symbolizable
11
17
 
12
18
  AVAILABLE_CHECKS = %i[with in].freeze
13
- ERROR_TYPES = %i[content_type_invalid].freeze
19
+ ERROR_TYPES = %i[
20
+ content_type_invalid
21
+ spoofed_content_type
22
+ ].freeze
14
23
 
15
24
  def check_validity!
16
25
  ensure_exactly_one_validator_option
@@ -18,52 +27,102 @@ module ActiveStorageValidations
18
27
  end
19
28
 
20
29
  def validate_each(record, attribute, _value)
21
- return true unless record.send(attribute).attached?
30
+ return if no_attachments?(record, attribute)
22
31
 
23
- types = authorized_types(record)
24
- return true if types.empty?
32
+ @authorized_content_types = authorized_content_types_from_options(record)
33
+ return if @authorized_content_types.empty?
25
34
 
26
- files = Array.wrap(record.send(attribute))
27
-
28
- files.each do |file|
29
- next if is_valid?(file, types)
30
-
31
- errors_options = initialize_error_options(options, file)
32
- errors_options[:authorized_types] = types_to_human_format(types)
33
- errors_options[:content_type] = content_type(file)
34
- add_error(record, attribute, ERROR_TYPES.first, **errors_options)
35
- break
35
+ attachables_from_changes(record, attribute).each do |attachable|
36
+ set_attachable_cached_values(attachable)
37
+ is_valid?(record, attribute, attachable)
36
38
  end
37
39
  end
38
40
 
39
- def authorized_types(record)
40
- flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
41
+ private
42
+
43
+ def authorized_content_types_from_options(record)
44
+ flat_options = set_flat_options(record)
45
+
41
46
  (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in])).compact.map do |type|
42
- if type.is_a?(Regexp)
43
- type
44
- else
45
- Marcel::MimeType.for(declared_type: type.to_s, extension: type.to_s)
47
+ case type
48
+ when String, Symbol then Marcel::MimeType.for(declared_type: type.to_s, extension: type.to_s)
49
+ when Regexp then type
46
50
  end
47
51
  end
48
52
  end
49
53
 
50
- def types_to_human_format(types)
51
- types
52
- .map { |type| type.is_a?(Regexp) ? type.source : type.to_s.split('/').last.upcase }
53
- .join(', ')
54
+ def set_attachable_cached_values(attachable)
55
+ @attachable_content_type = attachable_content_type(attachable)
56
+ @attachable_filename = attachable_filename(attachable).to_s
54
57
  end
55
58
 
56
- def content_type(file)
57
- file.blob.present? && file.blob.content_type
59
+ def is_valid?(record, attribute, attachable)
60
+ extension_matches_content_type?(record, attribute, attachable) &&
61
+ authorized_content_type?(record, attribute, attachable) &&
62
+ not_spoofing_content_type?(record, attribute, attachable)
63
+ end
64
+
65
+ def extension_matches_content_type?(record, attribute, attachable)
66
+ extension = @attachable_filename.split('.').second
67
+
68
+ possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
69
+ return true if possible_extensions && extension.in?(possible_extensions)
70
+
71
+ errors_options = initialize_and_populate_error_options(options, attachable)
72
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
73
+ false
58
74
  end
59
75
 
60
- def is_valid?(file, types)
61
- file_type = content_type(file)
62
- types.any? do |type|
63
- type == file_type || (type.is_a?(Regexp) && type.match?(file_type.to_s))
76
+ def authorized_content_type?(record, attribute, attachable)
77
+ attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
78
+ case authorized_content_type
79
+ when String then authorized_content_type == marcel_attachable_content_type(attachable)
80
+ when Regexp then authorized_content_type.match?(marcel_attachable_content_type(attachable).to_s)
81
+ end
82
+ end
83
+
84
+ return true if attachable_content_type_is_authorized
85
+
86
+ errors_options = initialize_and_populate_error_options(options, attachable)
87
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
88
+ false
89
+ end
90
+
91
+ def not_spoofing_content_type?(record, attribute, attachable)
92
+ return true unless enable_spoofing_protection?
93
+
94
+ if ContentTypeSpoofDetector.new(record, attribute, attachable).spoofed?
95
+ errors_options = initialize_error_options(options, attachable)
96
+ add_error(record, attribute, ERROR_TYPES.second, **errors_options)
97
+ false
98
+ else
99
+ true
64
100
  end
65
101
  end
66
102
 
103
+ def marcel_attachable_content_type(attachable)
104
+ Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
105
+ end
106
+
107
+ def enable_spoofing_protection?
108
+ options[:spoofing_protection] == true
109
+ end
110
+
111
+ def initialize_and_populate_error_options(options, attachable)
112
+ errors_options = initialize_error_options(options, attachable)
113
+ errors_options[:content_type] = @attachable_content_type
114
+ errors_options[:authorized_types] = authorized_content_types_to_human_format
115
+ errors_options
116
+ end
117
+
118
+ def authorized_content_types_to_human_format
119
+ @authorized_content_types
120
+ .map do |authorized_content_type|
121
+ authorized_content_type.is_a?(Regexp) ? authorized_content_type.source : authorized_content_type.to_s.split('/').last.upcase
122
+ end
123
+ .join(', ')
124
+ end
125
+
67
126
  def ensure_exactly_one_validator_option
68
127
  unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
69
128
  raise ArgumentError, 'You must pass either :with or :in to the validator'
@@ -74,24 +133,39 @@ module ActiveStorageValidations
74
133
  return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
75
134
 
76
135
  ([options[:with]] || options[:in]).each do |content_type|
77
- raise ArgumentError, invalid_content_type_message(content_type) if invalid_content_type?(content_type)
136
+ raise ArgumentError, invalid_content_type_option_message(content_type) if invalid_option?(content_type)
78
137
  end
79
138
  end
80
139
 
81
- def invalid_content_type_message(content_type)
82
- <<~ERROR_MESSAGE
83
- You must pass valid content types to the validator
84
- '#{content_type}' is not find in Marcel::EXTENSIONS mimes
85
- ERROR_MESSAGE
140
+ def invalid_content_type_option_message(content_type)
141
+ if content_type.to_s.match?(/\//)
142
+ <<~ERROR_MESSAGE
143
+ You must pass valid content types to the validator
144
+ '#{content_type}' is not found in Marcel::TYPE_EXTS
145
+ ERROR_MESSAGE
146
+ else
147
+ <<~ERROR_MESSAGE
148
+ You must pass valid content types extensions to the validator
149
+ '#{content_type}' is not found in Marcel::EXTENSIONS
150
+ ERROR_MESSAGE
151
+ end
86
152
  end
87
153
 
88
- def invalid_content_type?(content_type)
154
+ def invalid_option?(content_type)
89
155
  case content_type
90
156
  when String, Symbol
91
- Marcel::MimeType.for(declared_type: content_type.to_s, extension: content_type.to_s) == 'application/octet-stream'
157
+ content_type.to_s.match?(/\//) ? invalid_content_type?(content_type) : invalid_extension?(content_type)
92
158
  when Regexp
93
159
  false # We always validate regexes
94
160
  end
95
161
  end
162
+
163
+ def invalid_content_type?(content_type)
164
+ Marcel::TYPE_EXTS[content_type.to_s] == nil
165
+ end
166
+
167
+ def invalid_extension?(content_type)
168
+ Marcel::MimeType.for(extension: content_type.to_s) == 'application/octet-stream'
169
+ end
96
170
  end
97
171
  end
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
4
+ require_relative 'concerns/attachable.rb'
3
5
  require_relative 'concerns/errorable.rb'
6
+ require_relative 'concerns/optionable.rb'
4
7
  require_relative 'concerns/symbolizable.rb'
5
- require_relative 'metadata.rb'
6
8
 
7
9
  module ActiveStorageValidations
8
10
  class DimensionValidator < ActiveModel::EachValidator # :nodoc
9
- include OptionProcUnfolding
11
+ include ActiveStorageable
12
+ include Attachable
10
13
  include Errorable
14
+ include Optionable
11
15
  include Symbolizable
12
16
 
13
17
  AVAILABLE_CHECKS = %i[width height min max].freeze
@@ -25,65 +29,19 @@ module ActiveStorageValidations
25
29
  dimension_height_equal_to
26
30
  ].freeze
27
31
 
28
- def process_options(record)
29
- flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
30
-
31
- [:width, :height].each do |length|
32
- if flat_options[length] and flat_options[length].is_a?(Hash)
33
- if (range = flat_options[length][:in])
34
- raise ArgumentError, ":in must be a Range" unless range.is_a?(Range)
35
- flat_options[length][:min], flat_options[length][:max] = range.min, range.max
36
- end
37
- end
38
- end
39
- [:min, :max].each do |dim|
40
- if (range = flat_options[dim])
41
- raise ArgumentError, ":#{dim} must be a Range (width..height)" unless range.is_a?(Range)
42
- flat_options[:width] = { dim => range.first }
43
- flat_options[:height] = { dim => range.last }
44
- end
45
- end
46
-
47
- flat_options
48
- end
49
-
50
32
  def check_validity!
51
33
  unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
52
34
  raise ArgumentError, 'You must pass either :width, :height, :min or :max to the validator'
53
35
  end
54
36
  end
55
37
 
38
+ def validate_each(record, attribute, _value)
39
+ return if no_attachments?(record, attribute)
56
40
 
57
- if Rails.gem_version >= Gem::Version.new('6.0.0')
58
- def validate_each(record, attribute, _value)
59
- return true unless record.send(attribute).attached?
60
-
61
- changes = record.attachment_changes[attribute.to_s]
62
- return true if changes.blank?
63
-
64
- files = Array.wrap(changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable)
65
- files.each do |file|
66
- metadata = Metadata.new(file).metadata
67
- next if is_valid?(record, attribute, file, metadata)
68
- break
69
- end
70
- end
71
- else
72
- # Rails 5
73
- def validate_each(record, attribute, _value)
74
- return true unless record.send(attribute).attached?
75
-
76
- files = Array.wrap(record.send(attribute))
77
- files.each do |file|
78
- # Analyze file first if not analyzed to get all required metadata.
79
- file.analyze; file.reload unless file.analyzed?
80
- metadata = file.metadata rescue {}
81
- next if is_valid?(record, attribute, file, metadata)
82
- break
83
- end
84
- end
41
+ validate_changed_files_from_metadata(record, attribute)
85
42
  end
86
43
 
44
+ private
87
45
 
88
46
  def is_valid?(record, attribute, file, metadata)
89
47
  flat_options = process_options(record)
@@ -163,5 +121,27 @@ module ActiveStorageValidations
163
121
 
164
122
  true # valid file
165
123
  end
124
+
125
+ def process_options(record)
126
+ flat_options = set_flat_options(record)
127
+
128
+ [:width, :height].each do |length|
129
+ if flat_options[length] and flat_options[length].is_a?(Hash)
130
+ if (range = flat_options[length][:in])
131
+ raise ArgumentError, ":in must be a Range" unless range.is_a?(Range)
132
+ flat_options[length][:min], flat_options[length][:max] = range.min, range.max
133
+ end
134
+ end
135
+ end
136
+ [:min, :max].each do |dim|
137
+ if (range = flat_options[dim])
138
+ raise ArgumentError, ":#{dim} must be a Range (width..height)" unless range.is_a?(Range)
139
+ flat_options[:width] = { dim => range.first }
140
+ flat_options[:height] = { dim => range.last }
141
+ end
142
+ end
143
+
144
+ flat_options
145
+ end
166
146
  end
167
147
  end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
3
4
  require_relative 'concerns/errorable.rb'
5
+ require_relative 'concerns/optionable.rb'
4
6
  require_relative 'concerns/symbolizable.rb'
5
7
 
6
8
  module ActiveStorageValidations
7
9
  class LimitValidator < ActiveModel::EachValidator # :nodoc:
8
- include OptionProcUnfolding
10
+ include ActiveStorageable
9
11
  include Errorable
12
+ include Optionable
10
13
  include Symbolizable
11
14
 
12
15
  AVAILABLE_CHECKS = %i[max min].freeze
@@ -19,11 +22,11 @@ module ActiveStorageValidations
19
22
  ensure_arguments_validity
20
23
  end
21
24
 
22
- def validate_each(record, attribute, _)
23
- files = Array.wrap(record.send(attribute)).reject { |file| file.blank? }.compact.uniq
24
- flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
25
+ def validate_each(record, attribute, _value)
26
+ files = attached_files(record, attribute).reject(&:blank?)
27
+ flat_options = set_flat_options(record)
25
28
 
26
- return true if files_count_valid?(files.count, flat_options)
29
+ return if files_count_valid?(files.count, flat_options)
27
30
 
28
31
  errors_options = initialize_error_options(options)
29
32
  errors_options[:min] = flat_options[:min]
@@ -0,0 +1,5 @@
1
+ Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)
2
+ Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %(audio/x-aac)
3
+ Marcel::MimeType.extend "audio/x-m4a", parents: %(audio/mp4)
4
+ Marcel::MimeType.extend "text/xml", parents: %(application/xml) # alias
5
+ Marcel::MimeType.extend "video/theora", parents: %(video/ogg)