active_storage_validations 0.9.7 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +737 -229
  3. data/config/locales/da.yml +53 -0
  4. data/config/locales/de.yml +50 -19
  5. data/config/locales/en.yml +50 -19
  6. data/config/locales/es.yml +50 -19
  7. data/config/locales/fr.yml +50 -19
  8. data/config/locales/it.yml +50 -19
  9. data/config/locales/ja.yml +50 -19
  10. data/config/locales/nl.yml +50 -19
  11. data/config/locales/pl.yml +50 -19
  12. data/config/locales/pt-BR.yml +50 -19
  13. data/config/locales/ru.yml +50 -19
  14. data/config/locales/sv.yml +53 -0
  15. data/config/locales/tr.yml +50 -19
  16. data/config/locales/uk.yml +50 -19
  17. data/config/locales/vi.yml +50 -19
  18. data/config/locales/zh-CN.yml +53 -0
  19. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  20. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  21. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +47 -0
  22. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +57 -0
  23. data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
  24. data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
  25. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  26. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  27. data/lib/active_storage_validations/analyzer.rb +87 -0
  28. data/lib/active_storage_validations/aspect_ratio_validator.rb +154 -99
  29. data/lib/active_storage_validations/attached_validator.rb +22 -5
  30. data/lib/active_storage_validations/base_comparison_validator.rb +71 -0
  31. data/lib/active_storage_validations/content_type_validator.rb +206 -25
  32. data/lib/active_storage_validations/dimension_validator.rb +105 -82
  33. data/lib/active_storage_validations/duration_validator.rb +55 -0
  34. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +49 -0
  35. data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
  36. data/lib/active_storage_validations/limit_validator.rb +75 -16
  37. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
  38. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
  39. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +140 -0
  40. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +94 -59
  41. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +97 -55
  42. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  43. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  44. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
  45. data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
  46. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
  47. data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
  48. data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +49 -0
  49. data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
  50. data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
  51. data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
  52. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
  53. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
  54. data/lib/active_storage_validations/matchers.rb +11 -16
  55. data/lib/active_storage_validations/processable_file_validator.rb +37 -0
  56. data/lib/active_storage_validations/railtie.rb +11 -0
  57. data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
  58. data/lib/active_storage_validations/shared/asv_analyzable.rb +80 -0
  59. data/lib/active_storage_validations/shared/asv_attachable.rb +204 -0
  60. data/lib/active_storage_validations/shared/asv_errorable.rb +40 -0
  61. data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
  62. data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
  63. data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
  64. data/lib/active_storage_validations/size_validator.rb +24 -40
  65. data/lib/active_storage_validations/total_size_validator.rb +51 -0
  66. data/lib/active_storage_validations/version.rb +1 -1
  67. data/lib/active_storage_validations.rb +20 -6
  68. metadata +127 -21
  69. data/lib/active_storage_validations/metadata.rb +0 -123
@@ -1,51 +1,232 @@
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:
13
+ include ASVActiveStorageable
14
+ include ASVAnalyzable
15
+ include ASVAttachable
16
+ include ASVErrorable
17
+ include ASVOptionable
18
+ include ASVSymbolizable
19
+
20
+ AVAILABLE_CHECKS = %i[with in].freeze
21
+ ERROR_TYPES = %i[
22
+ content_type_invalid
23
+ content_type_spoofed
24
+ ].freeze
25
+ METADATA_KEYS = %i[content_type].freeze
26
+
27
+ def check_validity!
28
+ ensure_exactly_one_validator_option
29
+ ensure_content_types_validity
30
+ end
31
+
5
32
  def validate_each(record, attribute, _value)
6
- return true if !record.send(attribute).attached? || types.empty?
33
+ return if no_attachments?(record, attribute)
34
+
35
+ @authorized_content_types = authorized_content_types_from_options(record)
36
+ return if @authorized_content_types.empty?
7
37
 
8
- files = Array.wrap(record.send(attribute))
38
+ attachables_and_blobs(record, attribute).each do |attachable, blob|
39
+ set_attachable_cached_values(blob)
40
+ is_valid?(record, attribute, attachable, blob)
41
+ end
42
+ end
9
43
 
10
- errors_options = { authorized_types: types_to_human_format }
11
- errors_options[:message] = options[:message] if options[:message].present?
44
+ private
12
45
 
13
- files.each do |file|
14
- next if is_valid?(file)
46
+ def authorized_content_types_from_options(record)
47
+ flat_options = set_flat_options(record)
15
48
 
16
- errors_options[:content_type] = content_type(file)
17
- record.errors.add(attribute, :content_type_invalid, **errors_options)
18
- break
49
+ (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in])).compact.map do |type|
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
19
54
  end
20
55
  end
21
56
 
22
- def types
23
- return @types if defined? @types
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)
24
83
 
25
- @types = (Array.wrap(options[:with]) + Array.wrap(options[:in])).compact.map do |type|
26
- if type.is_a?(Regexp)
27
- type
28
- else
29
- Marcel::MimeType.for(declared_type: type.to_s, extension: type.to_s)
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)
30
94
  end
31
95
  end
96
+
97
+ return true if attachable_content_type_is_authorized
98
+
99
+ errors_options = initialize_and_populate_error_options(options, attachable)
100
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
101
+ false
102
+ end
103
+
104
+ def marcel_attachable_content_type(attachable)
105
+ Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
106
+ end
107
+
108
+ def not_spoofing_content_type?(record, attribute, attachable, blob)
109
+ return true unless enable_spoofing_protection?
110
+
111
+ @detected_content_type = metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
112
+
113
+ if attachable_content_type_vs_detected_content_type_mismatch?
114
+ errors_options = initialize_and_populate_error_options(options, attachable)
115
+ errors_options[:detected_content_type] = @detected_content_type
116
+ errors_options[:detected_human_content_type] = content_type_to_human_format(@detected_content_type)
117
+ add_error(record, attribute, ERROR_TYPES.second, **errors_options)
118
+ false
119
+ else
120
+ true
121
+ end
122
+ end
123
+
124
+ def disable_spoofing_protection?
125
+ !enable_spoofing_protection?
126
+ end
127
+
128
+ def enable_spoofing_protection?
129
+ options[:spoofing_protection] == true
130
+ end
131
+
132
+ def attachable_content_type_vs_detected_content_type_mismatch?
133
+ @attachable_content_type.present? &&
134
+ !attachable_content_type_intersects_detected_content_type?
135
+ end
136
+
137
+ def attachable_content_type_intersects_detected_content_type?
138
+ # Ruby intersects? method is only available from 3.1
139
+ enlarged_content_type(content_type_without_parameters(@attachable_content_type)).any? do |item|
140
+ enlarged_content_type(content_type_without_parameters(@detected_content_type)).include?(item)
141
+ end
142
+ end
143
+
144
+ def enlarged_content_type(content_type)
145
+ [content_type, *parent_content_types(content_type)].compact.uniq
32
146
  end
33
147
 
34
- def types_to_human_format
35
- types
36
- .map { |type| type.to_s.split('/').last.upcase }
148
+ def parent_content_types(content_type)
149
+ Marcel::TYPE_PARENTS[content_type] || []
150
+ end
151
+
152
+ def initialize_and_populate_error_options(options, attachable)
153
+ errors_options = initialize_error_options(options, attachable)
154
+ errors_options[:content_type] = @attachable_content_type
155
+ errors_options[:human_content_type] = content_type_to_human_format(@attachable_content_type)
156
+ errors_options[:authorized_human_content_types] = content_type_to_human_format(@authorized_content_types)
157
+ errors_options[:count] = @authorized_content_types.size
158
+ errors_options
159
+ end
160
+
161
+ def content_type_to_human_format(content_type)
162
+ Array(content_type)
163
+ .map do |content_type|
164
+ case content_type
165
+ when String, Symbol
166
+ content_type.to_s.match?(/\//) ? Marcel::TYPE_EXTS[content_type.to_s]&.first&.upcase : content_type.upcase
167
+ when Regexp
168
+ content_type.source
169
+ end
170
+ end
171
+ .flatten
172
+ .compact
37
173
  .join(', ')
38
174
  end
39
175
 
40
- def content_type(file)
41
- file.blob.present? && file.blob.content_type
176
+ def ensure_exactly_one_validator_option
177
+ unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
178
+ raise ArgumentError, 'You must pass either :with or :in to the validator'
179
+ end
180
+ end
181
+
182
+ def ensure_content_types_validity
183
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
184
+
185
+ (Array(options[:with]) + Array(options[:in])).each do |content_type|
186
+ raise ArgumentError, invalid_content_type_option_message(content_type) if invalid_option?(content_type)
187
+ end
188
+ end
189
+
190
+ def invalid_content_type_option_message(content_type)
191
+ if content_type.to_s.match?(/\//)
192
+ <<~ERROR_MESSAGE
193
+ You must pass valid content types to the validator
194
+ '#{content_type}' is not found in Marcel content types (Marcel::TYPE_EXTS + Marcel::MAGIC)
195
+ ERROR_MESSAGE
196
+ else
197
+ <<~ERROR_MESSAGE
198
+ You must pass valid content types extensions to the validator
199
+ '#{content_type}' is not found in Marcel::EXTENSIONS
200
+ ERROR_MESSAGE
201
+ end
202
+ end
203
+
204
+ def invalid_option?(content_type)
205
+ case content_type
206
+ when String, Symbol
207
+ content_type.to_s.match?(/\//) ? invalid_content_type?(content_type) : invalid_extension?(content_type)
208
+ when Regexp
209
+ false # We always validate regexes
210
+ end
42
211
  end
43
212
 
44
- def is_valid?(file)
45
- file_type = content_type(file)
46
- types.any? do |type|
47
- type == file_type || (type.is_a?(Regexp) && type.match?(file_type.to_s))
213
+ def invalid_content_type?(content_type)
214
+ if content_type == 'image/jpg'
215
+ raise ArgumentError, "'image/jpg' is not a valid content type, you should use 'image/jpeg' instead"
48
216
  end
217
+
218
+ all_available_marcel_content_types.exclude?(content_type.to_s)
219
+ end
220
+
221
+ def all_available_marcel_content_types
222
+ @all_available_marcel_content_types ||= Marcel::TYPE_EXTS
223
+ .keys
224
+ .push(*Marcel::MAGIC.map(&:first))
225
+ .tap(&:uniq!)
226
+ end
227
+
228
+ def invalid_extension?(content_type)
229
+ Marcel::MimeType.for(extension: content_type.to_s) == 'application/octet-stream'
49
230
  end
50
231
  end
51
232
  end
@@ -1,127 +1,150 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'metadata.rb'
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'
4
9
 
5
10
  module ActiveStorageValidations
6
11
  class DimensionValidator < ActiveModel::EachValidator # :nodoc
12
+ include ASVActiveStorageable
13
+ include ASVAnalyzable
14
+ include ASVAttachable
15
+ include ASVErrorable
16
+ include ASVOptionable
17
+ include ASVSymbolizable
18
+
7
19
  AVAILABLE_CHECKS = %i[width height min max].freeze
20
+ ERROR_TYPES = %i[
21
+ dimension_min_not_included_in
22
+ dimension_max_not_included_in
23
+ dimension_width_not_included_in
24
+ dimension_height_not_included_in
25
+ dimension_width_not_greater_than_or_equal_to
26
+ dimension_height_not_greater_than_or_equal_to
27
+ dimension_width_not_less_than_or_equal_to
28
+ dimension_height_not_less_than_or_equal_to
29
+ dimension_width_not_equal_to
30
+ dimension_height_not_equal_to
31
+ media_metadata_missing
32
+ ].freeze
33
+ METADATA_KEYS = %i[width height].freeze
8
34
 
9
- def initialize(options)
10
- [:width, :height].each do |length|
11
- if options[length] and options[length].is_a?(Hash)
12
- if range = options[length][:in]
13
- raise ArgumentError, ":in must be a Range" unless range.is_a?(Range)
14
- options[length][:min], options[length][:max] = range.min, range.max
15
- end
16
- end
17
- end
18
- [:min, :max].each do |dim|
19
- if range = options[dim]
20
- raise ArgumentError, ":#{dim} must be a Range (width..height)" unless range.is_a?(Range)
21
- options[:width] = { dim => range.first }
22
- options[:height] = { dim => range.last }
23
- end
35
+ def check_validity!
36
+ unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
37
+ raise ArgumentError, 'You must pass either :width, :height, :min or :max to the validator'
24
38
  end
25
- super
26
39
  end
27
40
 
41
+ def validate_each(record, attribute, _value)
42
+ return if no_attachments?(record, attribute)
28
43
 
29
- def check_validity!
30
- return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
31
- raise ArgumentError, 'You must pass either :width, :height, :min or :max to the validator'
44
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
32
45
  end
33
46
 
47
+ private
34
48
 
35
- if Rails.gem_version >= Gem::Version.new('6.0.0')
36
- def validate_each(record, attribute, _value)
37
- return true unless record.send(attribute).attached?
38
-
39
- changes = record.attachment_changes[attribute.to_s]
40
- return true if changes.blank?
41
-
42
- files = Array.wrap(changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable)
43
- files.each do |file|
44
- metadata = Metadata.new(file).metadata
45
- next if is_valid?(record, attribute, metadata)
46
- break
47
- end
48
- end
49
- else
50
- # Rails 5
51
- def validate_each(record, attribute, _value)
52
- return true unless record.send(attribute).attached?
53
-
54
- files = Array.wrap(record.send(attribute))
55
- files.each do |file|
56
- # Analyze file first if not analyzed to get all required metadata.
57
- file.analyze; file.reload unless file.analyzed?
58
- metadata = file.metadata rescue {}
59
- next if is_valid?(record, attribute, metadata)
60
- break
61
- end
62
- end
63
- end
64
-
49
+ def is_valid?(record, attribute, file, metadata)
50
+ flat_options = process_options(record)
51
+ errors_options = initialize_error_options(options, file)
65
52
 
66
- def is_valid?(record, attribute, file_metadata)
67
53
  # Validation fails unless file metadata contains valid width and height.
68
- if file_metadata[:width].to_i <= 0 || file_metadata[:height].to_i <= 0
69
- add_error(record, attribute, options[:message].presence || :image_metadata_missing)
54
+ if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
55
+ add_error(record, attribute, :media_metadata_missing, **errors_options)
70
56
  return false
71
57
  end
72
58
 
73
59
  # Validation based on checks :min and :max (:min, :max has higher priority to :width, :height).
74
- if options[:min] || options[:max]
75
- if options[:min] && (
76
- (options[:width][:min] && file_metadata[:width] < options[:width][:min]) ||
77
- (options[:height][:min] && file_metadata[:height] < options[:height][:min])
60
+ if flat_options[:min] || flat_options[:max]
61
+ if flat_options[:min] && (
62
+ (flat_options[:width][:min] && metadata[:width] < flat_options[:width][:min]) ||
63
+ (flat_options[:height][:min] && metadata[:height] < flat_options[:height][:min])
78
64
  )
79
- add_error(record, attribute, options[:message].presence || :"dimension_min_inclusion", width: options[:width][:min], height: options[:height][:min])
65
+ errors_options[:width] = flat_options[:width][:min]
66
+ errors_options[:height] = flat_options[:height][:min]
67
+
68
+ add_error(record, attribute, :dimension_min_not_included_in, **errors_options)
80
69
  return false
81
70
  end
82
- if options[:max] && (
83
- (options[:width][:max] && file_metadata[:width] > options[:width][:max]) ||
84
- (options[:height][:max] && file_metadata[:height] > options[:height][:max])
71
+ if flat_options[:max] && (
72
+ (flat_options[:width][:max] && metadata[:width] > flat_options[:width][:max]) ||
73
+ (flat_options[:height][:max] && metadata[:height] > flat_options[:height][:max])
85
74
  )
86
- add_error(record, attribute, options[:message].presence || :"dimension_max_inclusion", width: options[:width][:max], height: options[:height][:max])
75
+ errors_options[:width] = flat_options[:width][:max]
76
+ errors_options[:height] = flat_options[:height][:max]
77
+
78
+ add_error(record, attribute, :dimension_max_not_included_in, **errors_options)
87
79
  return false
88
80
  end
89
81
 
90
82
  # Validation based on checks :width and :height.
91
83
  else
84
+ width_or_height_invalid = false
85
+
92
86
  [:width, :height].each do |length|
93
- next unless options[length]
94
- if options[length].is_a?(Hash)
95
- if options[length][:in] && (file_metadata[length] < options[length][:min] || file_metadata[length] > options[length][:max])
96
- add_error(record, attribute, options[:message].presence || :"dimension_#{length}_inclusion", min: options[length][:min], max: options[length][:max])
97
- return false
87
+ next unless flat_options[length]
88
+ if flat_options[length].is_a?(Hash)
89
+ if flat_options[length][:in] && (metadata[length] < flat_options[length][:min] || metadata[length] > flat_options[length][:max])
90
+ error_type = :"dimension_#{length}_not_included_in"
91
+ errors_options[:min] = flat_options[length][:min]
92
+ errors_options[:max] = flat_options[length][:max]
93
+
94
+ add_error(record, attribute, error_type, **errors_options)
95
+ width_or_height_invalid = true
98
96
  else
99
- if options[length][:min] && file_metadata[length] < options[length][:min]
100
- add_error(record, attribute, options[:message].presence || :"dimension_#{length}_greater_than_or_equal_to", length: options[length][:min])
101
- return false
102
- end
103
- if options[length][:max] && file_metadata[length] > options[length][:max]
104
- add_error(record, attribute, options[:message].presence || :"dimension_#{length}_less_than_or_equal_to", length: options[length][:max])
105
- return false
97
+ if flat_options[length][:min] && metadata[length] < flat_options[length][:min]
98
+ error_type = :"dimension_#{length}_not_greater_than_or_equal_to"
99
+ errors_options[:length] = flat_options[length][:min]
100
+
101
+ add_error(record, attribute, error_type, **errors_options)
102
+ width_or_height_invalid = true
103
+ elsif flat_options[length][:max] && metadata[length] > flat_options[length][:max]
104
+ error_type = :"dimension_#{length}_not_less_than_or_equal_to"
105
+ errors_options[:length] = flat_options[length][:max]
106
+
107
+ add_error(record, attribute, error_type, **errors_options)
108
+ width_or_height_invalid = true
106
109
  end
107
110
  end
108
111
  else
109
- if file_metadata[length] != options[length]
110
- add_error(record, attribute, options[:message].presence || :"dimension_#{length}_equal_to", length: options[length])
111
- return false
112
+ if metadata[length] != flat_options[length]
113
+ error_type = :"dimension_#{length}_not_equal_to"
114
+ errors_options[:length] = flat_options[length]
115
+
116
+ add_error(record, attribute, error_type, **errors_options)
117
+ width_or_height_invalid = true
112
118
  end
113
119
  end
114
120
  end
121
+
122
+ return false if width_or_height_invalid
115
123
  end
116
124
 
117
125
  true # valid file
118
126
  end
119
127
 
120
- def add_error(record, attribute, type, **attrs)
121
- key = options[:message].presence || type
122
- return if record.errors.added?(attribute, key)
123
- record.errors.add(attribute, key, **attrs)
124
- end
128
+ def process_options(record)
129
+ flat_options = set_flat_options(record)
130
+
131
+ [:width, :height].each do |length|
132
+ if flat_options[length] and flat_options[length].is_a?(Hash)
133
+ if (range = flat_options[length][:in])
134
+ raise ArgumentError, ":in must be a Range" unless range.is_a?(Range)
135
+ flat_options[length][:min], flat_options[length][:max] = range.min, range.max
136
+ end
137
+ end
138
+ end
139
+ [:min, :max].each do |dim|
140
+ if (range = flat_options[dim])
141
+ raise ArgumentError, ":#{dim} must be a Range (width..height)" unless range.is_a?(Range)
142
+ flat_options[:width] = { dim => range.first }
143
+ flat_options[:height] = { dim => range.last }
144
+ end
145
+ end
125
146
 
147
+ flat_options
148
+ end
126
149
  end
127
150
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_comparison_validator'
4
+
5
+ module ActiveStorageValidations
6
+ class DurationValidator < BaseComparisonValidator
7
+ include ASVAnalyzable
8
+ include ASVAttachable
9
+
10
+ ERROR_TYPES = %i[
11
+ duration_not_less_than
12
+ duration_not_less_than_or_equal_to
13
+ duration_not_greater_than
14
+ duration_not_greater_than_or_equal_to
15
+ duration_not_between
16
+ ].freeze
17
+ METADATA_KEYS = %i[duration].freeze
18
+
19
+ def validate_each(record, attribute, _value)
20
+ return if no_attachments?(record, attribute)
21
+
22
+ flat_options = set_flat_options(record)
23
+
24
+ attachables_and_blobs(record, attribute).each do |attachable, blob|
25
+ duration = metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:duration, nil)
26
+
27
+ if duration.to_i <= 0
28
+ errors_options = initialize_error_options(options, attachable)
29
+ add_error(record, attribute, :media_metadata_missing, **errors_options)
30
+ next
31
+ end
32
+
33
+ next if is_valid?(duration, flat_options)
34
+
35
+ errors_options = initialize_error_options(options, attachable)
36
+ populate_error_options(errors_options, flat_options)
37
+ errors_options[:duration] = format_bound_value(duration)
38
+
39
+ keys = AVAILABLE_CHECKS & flat_options.keys
40
+ error_type = "duration_not_#{keys.first}".to_sym
41
+
42
+ add_error(record, attribute, error_type, **errors_options)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def format_bound_value(value)
49
+ return nil unless value
50
+
51
+ custom_value = value == value.to_i ? value.to_i : value
52
+ ActiveSupport::Duration.build(custom_value).inspect
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ module ASVBlobMetadatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # This method returns the metadata that has been set by our gem.
9
+ # The metadata is stored in the blob's custom metadata. All keys are prefixed with 'asv_'
10
+ # to avoid conflicts with other metadata.
11
+ # It is not to set a active_storage_validation key equal to a a hash of our gem's metadata,
12
+ # because this would result in errors down the road with services such as S3.
13
+ #
14
+ # Because of how the metadata is stored, we need to convert the values from String
15
+ # to Integer or Boolean.
16
+ def active_storage_validations_metadata
17
+ metadata.dig('custom')
18
+ &.select { |key, _| key.to_s.start_with?('asv_') }
19
+ &.transform_keys { |key| key.to_s.delete_prefix('asv_') }
20
+ &.transform_values do |value|
21
+ case value
22
+ when /\A\d+\z/ then value.to_i
23
+ when /\A\d+\.\d+\z/ then value.to_f
24
+ when 'true' then true
25
+ when 'false' then false
26
+ else value
27
+ end
28
+ end || {}
29
+ end
30
+
31
+ # This method sets the metadata that has been detected by our gem.
32
+ # The metadata is stored in the blob's custom metadata. All keys are prefixed with 'asv_'.
33
+ # We need to store values as String, because services such as S3 will not accept other types.
34
+ def merge_into_active_storage_validations_metadata(hash)
35
+ aws_compatible_metadata = normalize_active_storage_validations_metadata_for_aws(hash)
36
+
37
+ metadata['custom'] ||= {}
38
+ metadata['custom'].merge!(aws_compatible_metadata)
39
+
40
+ active_storage_validations_metadata
41
+ end
42
+
43
+ def normalize_active_storage_validations_metadata_for_aws(hash)
44
+ hash.transform_keys { |key, _| key.to_s.start_with?('asv_') ? key : "asv_#{key}" }
45
+ .transform_values(&:to_s)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "marcel"
4
+
5
+ Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)
6
+ Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %(audio/x-aac)
7
+ Marcel::MimeType.extend "audio/x-m4a", parents: %(audio/mp4)
8
+ Marcel::MimeType.extend "text/xml", parents: %(application/xml) # alias
9
+ Marcel::MimeType.extend "video/theora", parents: %(video/ogg)
10
+
11
+ # Add empty content type
12
+ Marcel::MimeType.extend "inode/x-empty", extensions: %w(empty)