active_storage_validations 0.9.7 → 2.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 (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)