active_storage_validations 1.0.4 → 3.0.2

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +785 -245
  3. data/config/locales/da.yml +63 -0
  4. data/config/locales/de.yml +60 -19
  5. data/config/locales/en-GB.yml +63 -0
  6. data/config/locales/en.yml +60 -20
  7. data/config/locales/es.yml +60 -19
  8. data/config/locales/fr.yml +60 -19
  9. data/config/locales/it.yml +60 -19
  10. data/config/locales/ja.yml +60 -19
  11. data/config/locales/nl.yml +60 -19
  12. data/config/locales/pl.yml +60 -19
  13. data/config/locales/pt-BR.yml +60 -19
  14. data/config/locales/ru.yml +60 -19
  15. data/config/locales/sv.yml +63 -0
  16. data/config/locales/tr.yml +60 -19
  17. data/config/locales/uk.yml +60 -19
  18. data/config/locales/vi.yml +60 -19
  19. data/config/locales/zh-CN.yml +60 -19
  20. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  21. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  22. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +46 -0
  23. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +56 -0
  24. data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
  25. data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
  26. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +89 -0
  27. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  28. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  29. data/lib/active_storage_validations/analyzer.rb +88 -0
  30. data/lib/active_storage_validations/aspect_ratio_validator.rb +157 -97
  31. data/lib/active_storage_validations/attached_validator.rb +22 -5
  32. data/lib/active_storage_validations/base_comparison_validator.rb +83 -0
  33. data/lib/active_storage_validations/content_type_validator.rb +219 -31
  34. data/lib/active_storage_validations/dimension_validator.rb +187 -97
  35. data/lib/active_storage_validations/duration_validator.rb +70 -0
  36. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +56 -0
  37. data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
  38. data/lib/active_storage_validations/limit_validator.rb +76 -9
  39. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
  40. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
  41. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +150 -0
  42. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +98 -39
  43. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +93 -55
  44. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  45. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  46. data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +39 -0
  47. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
  48. data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
  49. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
  50. data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
  51. data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +57 -0
  52. data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
  53. data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
  54. data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
  55. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
  56. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
  57. data/lib/active_storage_validations/matchers.rb +17 -21
  58. data/lib/active_storage_validations/pages_validator.rb +61 -0
  59. data/lib/active_storage_validations/processable_file_validator.rb +37 -0
  60. data/lib/active_storage_validations/railtie.rb +14 -0
  61. data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
  62. data/lib/active_storage_validations/shared/asv_analyzable.rb +89 -0
  63. data/lib/active_storage_validations/shared/asv_attachable.rb +236 -0
  64. data/lib/active_storage_validations/shared/asv_errorable.rb +64 -0
  65. data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
  66. data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
  67. data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
  68. data/lib/active_storage_validations/size_validator.rb +24 -41
  69. data/lib/active_storage_validations/total_size_validator.rb +52 -0
  70. data/lib/active_storage_validations/version.rb +1 -1
  71. data/lib/active_storage_validations.rb +27 -13
  72. metadata +113 -31
  73. data/lib/active_storage_validations/metadata.rb +0 -151
  74. data/lib/active_storage_validations/option_proc_unfolding.rb +0 -16
  75. data/lib/active_storage_validations/processable_image_validator.rb +0 -43
@@ -1,14 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_errorable"
5
+ require_relative "shared/asv_symbolizable"
6
+
3
7
  module ActiveStorageValidations
4
8
  class AttachedValidator < ActiveModel::EachValidator # :nodoc:
5
- def validate_each(record, attribute, _value)
6
- return if record.send(attribute).attached?
9
+ include ASVActiveStorageable
10
+ include ASVErrorable
11
+ include ASVSymbolizable
12
+
13
+ ERROR_TYPES = %i[blank].freeze
7
14
 
8
- errors_options = {}
9
- errors_options[:message] = options[:message] if options[:message].present?
15
+ def check_validity!
16
+ %i[allow_nil allow_blank].each do |not_authorized_option|
17
+ if options.include?(not_authorized_option)
18
+ raise ArgumentError, "You cannot pass the :#{not_authorized_option} option to the #{self.class.to_sym} validator"
19
+ end
20
+ end
21
+ end
22
+
23
+ def validate_each(record, attribute, _value)
24
+ return if attachments_present?(record, attribute) &&
25
+ will_have_attachments_after_save?(record, attribute)
10
26
 
11
- record.errors.add(attribute, :blank, **errors_options)
27
+ errors_options = initialize_error_options(options)
28
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
12
29
  end
13
30
  end
14
31
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_errorable"
5
+ require_relative "shared/asv_optionable"
6
+ require_relative "shared/asv_symbolizable"
7
+
8
+ module ActiveStorageValidations
9
+ class BaseComparisonValidator < ActiveModel::EachValidator # :nodoc:
10
+ include ASVActiveStorageable
11
+ include ASVErrorable
12
+ include ASVOptionable
13
+ include ASVSymbolizable
14
+
15
+ AVAILABLE_CHECKS = %i[
16
+ less_than
17
+ less_than_or_equal_to
18
+ greater_than
19
+ greater_than_or_equal_to
20
+ between
21
+ equal_to
22
+ ].freeze
23
+
24
+ def initialize(*args)
25
+ if self.class == BaseComparisonValidator
26
+ raise NotImplementedError, "BaseComparisonValidator is an abstract class and cannot be instantiated directly."
27
+ end
28
+ super
29
+ end
30
+
31
+ def check_validity!
32
+ unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
33
+ raise ArgumentError, "You must pass either :less_than(_or_equal_to), :greater_than(_or_equal_to), :between or :equal_to to the validator"
34
+ end
35
+ end
36
+
37
+ def validate_each(record, attribute, value)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ private
42
+
43
+ def is_valid?(value, flat_options)
44
+ return false if value < 0
45
+
46
+ if flat_options[:between].present?
47
+ flat_options[:between].include?(value)
48
+ elsif flat_options[:less_than].present?
49
+ value < flat_options[:less_than]
50
+ elsif flat_options[:less_than_or_equal_to].present?
51
+ value <= flat_options[:less_than_or_equal_to]
52
+ elsif flat_options[:greater_than].present?
53
+ value > flat_options[:greater_than]
54
+ elsif flat_options[:greater_than_or_equal_to].present?
55
+ value >= flat_options[:greater_than_or_equal_to]
56
+ elsif flat_options[:equal_to].present?
57
+ value == flat_options[:equal_to]
58
+ end
59
+ end
60
+
61
+ def populate_error_options(errors_options, flat_options)
62
+ errors_options[:min] = format_bound_value(min(flat_options))
63
+ errors_options[:exact] = format_bound_value(exact(flat_options))
64
+ errors_options[:max] = format_bound_value(max(flat_options))
65
+ end
66
+
67
+ def format_bound_value
68
+ raise NotImplementedError
69
+ end
70
+
71
+ def min(flat_options)
72
+ flat_options[:between]&.min || flat_options[:greater_than] || flat_options[:greater_than_or_equal_to]
73
+ end
74
+
75
+ def exact(flat_options)
76
+ flat_options[:equal_to]
77
+ end
78
+
79
+ def max(flat_options)
80
+ flat_options[:between]&.max || flat_options[:less_than] || flat_options[:less_than_or_equal_to]
81
+ end
82
+ end
83
+ end
@@ -1,57 +1,245 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_analyzable"
5
+ require_relative "shared/asv_attachable"
6
+ require_relative "shared/asv_errorable"
7
+ require_relative "shared/asv_optionable"
8
+ require_relative "shared/asv_symbolizable"
9
+ require_relative "analyzer/content_type_analyzer"
10
+
3
11
  module ActiveStorageValidations
4
12
  class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
5
- include OptionProcUnfolding
13
+ include ASVActiveStorageable
14
+ include ASVAnalyzable
15
+ include ASVAttachable
16
+ include ASVErrorable
17
+ include ASVOptionable
18
+ include ASVSymbolizable
6
19
 
7
20
  AVAILABLE_CHECKS = %i[with in].freeze
8
-
9
- def validate_each(record, attribute, _value)
10
- return true unless record.send(attribute).attached?
21
+ ERROR_TYPES = %i[
22
+ content_type_invalid
23
+ content_type_spoofed
24
+ ].freeze
25
+ METADATA_KEYS = %i[content_type].freeze
11
26
 
12
- types = authorized_types(record)
13
- return true if types.empty?
14
-
15
- files = Array.wrap(record.send(attribute))
27
+ def check_validity!
28
+ ensure_exactly_one_validator_option
29
+ ensure_content_types_validity
30
+ end
16
31
 
17
- errors_options = { authorized_types: types_to_human_format(types) }
18
- errors_options[:message] = options[:message] if options[:message].present?
32
+ def validate_each(record, attribute, _value)
33
+ return if no_attachments?(record, attribute)
19
34
 
20
- files.each do |file|
21
- next if is_valid?(file, types)
35
+ @authorized_content_types = authorized_content_types_from_options(record)
36
+ return if @authorized_content_types.empty?
22
37
 
23
- errors_options[:content_type] = content_type(file)
24
- record.errors.add(attribute, :content_type_invalid, **errors_options)
25
- break
38
+ attachables_and_blobs(record, attribute).each do |attachable, blob|
39
+ set_attachable_cached_values(blob)
40
+ is_valid?(record, attribute, attachable, blob)
26
41
  end
27
42
  end
28
43
 
29
- def authorized_types(record)
30
- flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
44
+ private
45
+
46
+ def authorized_content_types_from_options(record)
47
+ flat_options = set_flat_options(record)
48
+
31
49
  (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in])).compact.map do |type|
32
- if type.is_a?(Regexp)
33
- type
34
- else
35
- Marcel::MimeType.for(declared_type: type.to_s, extension: type.to_s)
50
+ case type
51
+ when String, Symbol then Marcel::MimeType.for(declared_type: type.to_s, extension: type.to_s)
52
+ when Regexp then type
53
+ end
54
+ end
55
+ end
56
+
57
+ def set_attachable_cached_values(blob)
58
+ @attachable_content_type = blob.content_type
59
+ @attachable_filename = blob.filename.to_s
60
+ end
61
+
62
+ # Check if the provided content_type is authorized and not spoofed against
63
+ # the file io.
64
+ def is_valid?(record, attribute, attachable, blob)
65
+ authorized_content_type?(record, attribute, attachable) &&
66
+ not_spoofing_content_type?(record, attribute, attachable, blob)
67
+ end
68
+
69
+ # Dead code that we keep here for some time, maybe we will find a solution
70
+ # to this check later? (November 2024)
71
+ #
72
+ # We do not perform any validations against the extension because it is an
73
+ # unreliable source of truth. For example, a `.csv` file could have its
74
+ # `text/csv` content_type changed to `application/vnd.ms-excel` because
75
+ # it had been opened by Excel at some point, making the file extension vs
76
+ # file content_type check invalid.
77
+ # def extension_matches_content_type?(record, attribute, attachable)
78
+ # return true if !@attachable_filename || !@attachable_content_type
79
+
80
+ # extension = @attachable_filename.split('.').last
81
+ # possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
82
+ # return true if possible_extensions && extension.downcase.in?(possible_extensions)
83
+
84
+ # errors_options = initialize_and_populate_error_options(options, attachable)
85
+ # add_error(record, attribute, ERROR_TYPES.first, **errors_options)
86
+ # false
87
+ # end
88
+
89
+ def authorized_content_type?(record, attribute, attachable)
90
+ attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
91
+ case authorized_content_type
92
+ when String then authorized_content_type == marcel_attachable_content_type(attachable)
93
+ when Regexp then authorized_content_type.match?(marcel_attachable_content_type(attachable).to_s)
36
94
  end
37
95
  end
96
+
97
+ return true if attachable_content_type_is_authorized
98
+
99
+ add_content_type_invalid_error(record, attribute, attachable)
100
+ end
101
+
102
+ def marcel_attachable_content_type(attachable)
103
+ Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
104
+ end
105
+
106
+ def not_spoofing_content_type?(record, attribute, attachable, blob)
107
+ return true unless enable_spoofing_protection?
108
+
109
+ @detected_content_type = begin
110
+ metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
111
+ rescue ActiveStorage::FileNotFoundError
112
+ add_attachment_missing_error(record, attribute, attachable)
113
+ return false
114
+ end
115
+
116
+ if attachable_content_type_vs_detected_content_type_mismatch?
117
+ add_content_type_spoofed_error(record, attribute, attachable, @detected_content_type)
118
+ else
119
+ true
120
+ end
121
+ end
122
+
123
+ def disable_spoofing_protection?
124
+ !enable_spoofing_protection?
38
125
  end
39
126
 
40
- def types_to_human_format(types)
41
- types
42
- .map { |type| type.to_s.split('/').last.upcase }
43
- .join(', ')
127
+ def enable_spoofing_protection?
128
+ options[:spoofing_protection] == true
44
129
  end
45
130
 
46
- def content_type(file)
47
- file.blob.present? && file.blob.content_type
131
+ def attachable_content_type_vs_detected_content_type_mismatch?
132
+ @attachable_content_type.present? &&
133
+ !attachable_content_type_intersects_detected_content_type?
48
134
  end
49
135
 
50
- def is_valid?(file, types)
51
- file_type = content_type(file)
52
- types.any? do |type|
53
- type == file_type || (type.is_a?(Regexp) && type.match?(file_type.to_s))
136
+ def attachable_content_type_intersects_detected_content_type?
137
+ # Ruby intersects? method is only available from 3.1
138
+ enlarged_content_type(content_type_without_parameters(@attachable_content_type)).any? do |item|
139
+ enlarged_content_type(content_type_without_parameters(@detected_content_type)).include?(item)
54
140
  end
55
141
  end
142
+
143
+ def enlarged_content_type(content_type)
144
+ [ content_type, *parent_content_types(content_type) ].compact.uniq
145
+ end
146
+
147
+ def parent_content_types(content_type)
148
+ Marcel::TYPE_PARENTS[content_type] || []
149
+ end
150
+
151
+ def add_content_type_invalid_error(record, attribute, attachable)
152
+ errors_options = initialize_and_populate_error_options(options, attachable)
153
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
154
+ false
155
+ end
156
+
157
+ def add_content_type_spoofed_error(record, attribute, attachable, detected_content_type)
158
+ errors_options = initialize_and_populate_error_options(options, attachable)
159
+ errors_options[:detected_content_type] = @detected_content_type
160
+ errors_options[:detected_human_content_type] = content_type_to_human_format(@detected_content_type)
161
+ add_error(record, attribute, ERROR_TYPES.second, **errors_options)
162
+ false
163
+ end
164
+
165
+ def initialize_and_populate_error_options(options, attachable)
166
+ errors_options = initialize_error_options(options, attachable)
167
+ errors_options[:content_type] = @attachable_content_type
168
+ errors_options[:human_content_type] = content_type_to_human_format(@attachable_content_type)
169
+ errors_options[:authorized_human_content_types] = content_type_to_human_format(@authorized_content_types)
170
+ errors_options[:count] = @authorized_content_types.size
171
+ errors_options
172
+ end
173
+
174
+ def content_type_to_human_format(content_type)
175
+ Array(content_type)
176
+ .map do |content_type|
177
+ case content_type
178
+ when String, Symbol
179
+ content_type.to_s.match?(/\//) ? Marcel::TYPE_EXTS[content_type.to_s]&.first&.upcase : content_type.upcase
180
+ when Regexp
181
+ content_type.source
182
+ end
183
+ end
184
+ .flatten
185
+ .compact
186
+ .join(", ")
187
+ end
188
+
189
+ def ensure_exactly_one_validator_option
190
+ unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
191
+ raise ArgumentError, "You must pass either :with or :in to the validator"
192
+ end
193
+ end
194
+
195
+ def ensure_content_types_validity
196
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
197
+
198
+ (Array(options[:with]) + Array(options[:in])).each do |content_type|
199
+ raise ArgumentError, invalid_content_type_option_message(content_type) if invalid_option?(content_type)
200
+ end
201
+ end
202
+
203
+ def invalid_content_type_option_message(content_type)
204
+ if content_type.to_s.match?(/\//)
205
+ <<~ERROR_MESSAGE
206
+ You must pass valid content types to the validator
207
+ '#{content_type}' is not found in Marcel content types (Marcel::TYPE_EXTS + Marcel::MAGIC)
208
+ ERROR_MESSAGE
209
+ else
210
+ <<~ERROR_MESSAGE
211
+ You must pass valid content types extensions to the validator
212
+ '#{content_type}' is not found in Marcel::EXTENSIONS
213
+ ERROR_MESSAGE
214
+ end
215
+ end
216
+
217
+ def invalid_option?(content_type)
218
+ case content_type
219
+ when String, Symbol
220
+ content_type.to_s.match?(/\//) ? invalid_content_type?(content_type) : invalid_extension?(content_type)
221
+ when Regexp
222
+ false # We always validate regexes
223
+ end
224
+ end
225
+
226
+ def invalid_content_type?(content_type)
227
+ if content_type == "image/jpg"
228
+ raise ArgumentError, "'image/jpg' is not a valid content type, you should use 'image/jpeg' instead"
229
+ end
230
+
231
+ all_available_marcel_content_types.exclude?(content_type.to_s)
232
+ end
233
+
234
+ def all_available_marcel_content_types
235
+ @all_available_marcel_content_types ||= Marcel::TYPE_EXTS
236
+ .keys
237
+ .push(*Marcel::MAGIC.map(&:first))
238
+ .tap(&:uniq!)
239
+ end
240
+
241
+ def invalid_extension?(content_type)
242
+ Marcel::MimeType.for(extension: content_type.to_s) == "application/octet-stream"
243
+ end
56
244
  end
57
245
  end