active_storage_validations 4.0.0 → 4.1.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 +71 -8
  3. data/config/locales/da.yml +2 -0
  4. data/config/locales/de.yml +2 -0
  5. data/config/locales/en-GB.yml +2 -0
  6. data/config/locales/en.yml +2 -0
  7. data/config/locales/es.yml +2 -0
  8. data/config/locales/fr.yml +2 -0
  9. data/config/locales/it.yml +2 -0
  10. data/config/locales/ja.yml +2 -0
  11. data/config/locales/nl.yml +2 -0
  12. data/config/locales/pl.yml +2 -0
  13. data/config/locales/pt-BR.yml +2 -0
  14. data/config/locales/ru.yml +2 -0
  15. data/config/locales/sv.yml +4 -2
  16. data/config/locales/tr.yml +2 -0
  17. data/config/locales/uk.yml +2 -0
  18. data/config/locales/vi.yml +2 -0
  19. data/config/locales/zh-CN.yml +2 -0
  20. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +7 -1
  21. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +0 -2
  22. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +0 -2
  23. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +3 -0
  24. data/lib/active_storage_validations/analyzer/image_analyzer.rb +16 -6
  25. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +11 -2
  26. data/lib/active_storage_validations/analyzer.rb +2 -35
  27. data/lib/active_storage_validations/aspect_ratio_validator.rb +21 -15
  28. data/lib/active_storage_validations/asv_attachable_adapter.rb +221 -0
  29. data/lib/active_storage_validations/content_type_validator.rb +40 -64
  30. data/lib/active_storage_validations/duration_validator.rb +3 -1
  31. data/lib/active_storage_validations/engine.rb +3 -0
  32. data/lib/active_storage_validations/extensors/asv_marcelable.rb +5 -5
  33. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -4
  34. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +2 -1
  35. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +1 -0
  36. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +1 -1
  37. data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
  38. data/lib/active_storage_validations/matchers.rb +22 -10
  39. data/lib/active_storage_validations/shared/asv_analyzable.rb +51 -8
  40. data/lib/active_storage_validations/shared/asv_attachable.rb +14 -97
  41. data/lib/active_storage_validations/shared/asv_errorable.rb +9 -8
  42. data/lib/active_storage_validations/version.rb +1 -1
  43. data/lib/active_storage_validations/with_audio_validator.rb +43 -0
  44. data/lib/active_storage_validations.rb +1 -0
  45. metadata +5 -2
@@ -8,7 +8,11 @@ module ActiveStorageValidations
8
8
  module ASVAnalyzable
9
9
  extend ActiveSupport::Concern
10
10
 
11
- DEFAULT_IMAGE_PROCESSOR = :mini_magick.freeze
11
+ DEFAULT_IMAGE_PROCESSOR = :mini_magick
12
+ # Keys written by the content-type sniffers. They record what the file
13
+ # claims to be, not whether an analyzer could decode it, so they must never
14
+ # count as evidence that a media analysis happened.
15
+ CONTENT_TYPE_METADATA_KEYS = %i[content_type content_type_backend].freeze
12
16
 
13
17
  private
14
18
 
@@ -21,13 +25,42 @@ module ActiveStorageValidations
21
25
  return content_type_metadata_for(blob, attachable)
22
26
  end
23
27
 
24
- return blob.active_storage_validations_metadata if blob_has_asv_metadata?(blob, metadata_keys)
28
+ return media_metadata(blob) if blob_has_asv_metadata?(blob, metadata_keys)
25
29
 
26
30
  new_metadata = generate_metadata_for(attachable, metadata_keys) || {}
27
- blob.merge_into_active_storage_validations_metadata(new_metadata)
28
- blob.save!
31
+ blob.merge_into_active_storage_validations_metadata(memoize_unavailable_keys(new_metadata, metadata_keys))
32
+ persist_asv_metadata(blob)
29
33
 
30
- blob.active_storage_validations_metadata
34
+ media_metadata(blob)
35
+ end
36
+
37
+ # The metadata produced by the media analyzers, i.e. everything the blob
38
+ # carries except the content-type sniffer keys. Validators that ask for an
39
+ # empty METADATA_KEYS (ProcessableFileValidator) treat a non-empty result as
40
+ # proof that an analyzer decoded the file, so a cached +asv_content_type+
41
+ # left behind by spoofing_protection must not leak into it.
42
+ def media_metadata(blob)
43
+ blob.active_storage_validations_metadata.except(*CONTENT_TYPE_METADATA_KEYS)
44
+ end
45
+
46
+ # Analyzers only return the keys they could extract, so a requested key the
47
+ # analyzer cannot produce for this file (e.g. :duration for an image) would
48
+ # never be cached and the expensive analysis would run again on every
49
+ # validation. Store those keys as blank to memoize the miss: the validator
50
+ # still sees no usable value and adds its usual error, but only analyzes once.
51
+ #
52
+ # An entirely empty result means no analysis happened at all — missing CLI,
53
+ # timed out command, unreadable file — so it is left unmemoized and retried
54
+ # on the next validation.
55
+ #
56
+ # Teaching an existing analyzer a new metadata key therefore requires
57
+ # clearing the misses memoized by previous versions of the gem, see
58
+ # ASVBlobMetadatable#remove_active_storage_validations_metadata!.
59
+ def memoize_unavailable_keys(new_metadata, metadata_keys)
60
+ return new_metadata if new_metadata.blank?
61
+
62
+ unavailable_keys = metadata_keys.reject { |key| new_metadata.key?(key) }
63
+ new_metadata.merge(unavailable_keys.index_with(nil))
31
64
  end
32
65
 
33
66
  def content_type_metadata_keys?(metadata_keys)
@@ -48,11 +81,20 @@ module ActiveStorageValidations
48
81
  return failed_content_type_metadata(backend) if content_type_analysis_failed?(new_metadata)
49
82
 
50
83
  blob.merge_into_active_storage_validations_metadata(new_metadata)
51
- blob.save!
84
+ persist_asv_metadata(blob)
52
85
 
53
86
  blob.active_storage_validations_metadata
54
87
  end
55
88
 
89
+ # A bare +valid?+ on a new record must not INSERT an +active_storage_blobs+
90
+ # row (no file on the service, orphaned if the record is never saved).
91
+ # In-memory +asv_*+ on the unsaved blob is persisted by Rails when the
92
+ # record is saved. Already-persisted blobs (re-validation, attach after
93
+ # save, pre-v2 blobs) still need +save!+ so the cache survives +reload+.
94
+ def persist_asv_metadata(blob)
95
+ blob.save! if blob.persisted?
96
+ end
97
+
56
98
  def content_type_analysis_failed?(new_metadata)
57
99
  new_metadata.blank? || new_metadata[:content_type].blank?
58
100
  end
@@ -74,9 +116,10 @@ module ActiveStorageValidations
74
116
  end
75
117
 
76
118
  def blob_has_asv_metadata?(blob, metadata_keys)
77
- return false unless blob.active_storage_validations_metadata.present?
119
+ cached = media_metadata(blob)
120
+ return false unless cached.present?
78
121
 
79
- metadata_keys.all? { |key| blob.active_storage_validations_metadata.key?(key) }
122
+ metadata_keys.all? { |key| cached.key?(key) }
80
123
  end
81
124
 
82
125
  def generate_metadata_for(attachable, metadata_keys)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../asv_attachable_adapter"
4
+
3
5
  module ActiveStorageValidations
4
6
  # ActiveStorageValidations::ASVAttachable
5
7
  #
@@ -66,35 +68,17 @@ module ActiveStorageValidations
66
68
  end
67
69
  end
68
70
 
69
- # Retrieve the full declared content_type from attachable.
70
- # rubocop:disable Metrics/MethodLength
71
71
  def full_attachable_content_type(attachable)
72
- case attachable
73
- when ActiveStorage::Blob
74
- attachable.content_type
75
- when ActionDispatch::Http::UploadedFile
76
- attachable.content_type
77
- when Rack::Test::UploadedFile
78
- attachable.content_type
79
- when String
80
- blob = ActiveStorage::Blob.find_signed!(attachable)
81
- blob.content_type
82
- when Hash
83
- attachable[:content_type]
84
- when File
85
- supports_file_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
86
- when Pathname
87
- supports_pathname_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
88
- else
89
- raise_rails_like_error(attachable)
90
- end
72
+ wrap_attachable(attachable).content_type
91
73
  end
92
- # rubocop:enable Metrics/MethodLength
93
74
 
94
75
  # Retrieve the declared content_type from attachable without potential mime
95
76
  # type parameters (e.g. 'application/x-rar-compressed;version=5')
96
77
  def attachable_content_type(attachable)
97
- (full_attachable_content_type(attachable) && content_type_without_parameters(full_attachable_content_type(attachable)) || marcel_content_type_from_filename(attachable))
78
+ declared_content_type = full_attachable_content_type(attachable)
79
+
80
+ (declared_content_type && content_type_without_parameters(declared_content_type)) ||
81
+ marcel_content_type_from_filename(attachable)
98
82
  end
99
83
 
100
84
  # Remove the potential mime type parameters from the content_type (e.g.
@@ -120,90 +104,23 @@ module ActiveStorageValidations
120
104
  (full_attachable_content_type(attachable) || marcel_content_type_from_filename(attachable)).split("/").first
121
105
  end
122
106
 
123
- # Retrieve the io from attachable.
124
- # rubocop:disable Metrics/MethodLength
125
- # rubocop:disable Metrics/AbcSize
126
107
  def attachable_io(attachable, max_byte_size: nil)
127
- io = case attachable
128
- when ActiveStorage::Blob
129
- max_byte_size ? attachable.download_chunk(0...max_byte_size) : attachable.download
130
- when ActionDispatch::Http::UploadedFile
131
- max_byte_size ? attachable.read(max_byte_size) : attachable.read
132
- when Rack::Test::UploadedFile
133
- max_byte_size ? attachable.read(max_byte_size) : attachable.read
134
- when String
135
- blob = ActiveStorage::Blob.find_signed!(attachable)
136
- max_byte_size ? blob.download_chunk(0...max_byte_size) : blob.download
137
- when Hash
138
- max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
139
- when File
140
- raise_rails_like_error(attachable) unless supports_file_attachment?
141
- max_byte_size ? attachable.read(max_byte_size) : attachable.read
142
- when Pathname
143
- raise_rails_like_error(attachable) unless supports_pathname_attachment?
144
- max_byte_size ? attachable.read(max_byte_size) : attachable.read
145
- else
146
- raise_rails_like_error(attachable)
147
- end
148
-
149
- rewind_attachable_io(attachable)
108
+ wrapped = wrap_attachable(attachable)
109
+ io = wrapped.read(max_byte_size: max_byte_size)
110
+ wrapped.rewind
150
111
  io
151
112
  end
152
- # rubocop:enable Metrics/MethodLength
153
- # rubocop:enable Metrics/AbcSize
154
113
 
155
- # Rewind the io attachable.
156
114
  def rewind_attachable_io(attachable)
157
- case attachable
158
- when ActiveStorage::Blob, String
159
- # nothing to do
160
- when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
161
- attachable.rewind
162
- when Hash
163
- attachable[:io].rewind
164
- when File
165
- raise_rails_like_error(attachable) unless supports_file_attachment?
166
- attachable.rewind
167
- when Pathname
168
- raise_rails_like_error(attachable) unless supports_pathname_attachment?
169
- File.open(attachable) { |f| f.rewind }
170
- else
171
- raise_rails_like_error(attachable)
172
- end
115
+ wrap_attachable(attachable).rewind
173
116
  end
174
117
 
175
- # Retrieve the declared filename from attachable.
176
- # rubocop:disable Metrics/MethodLength
177
118
  def attachable_filename(attachable)
178
- case attachable
179
- when ActiveStorage::Blob
180
- attachable.filename
181
- when ActionDispatch::Http::UploadedFile
182
- attachable.original_filename
183
- when Rack::Test::UploadedFile
184
- attachable.original_filename
185
- when String
186
- blob = ActiveStorage::Blob.find_signed!(attachable)
187
- blob.filename
188
- when Hash
189
- attachable[:filename]
190
- when File
191
- supports_file_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
192
- when Pathname
193
- supports_pathname_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
194
- else
195
- raise_rails_like_error(attachable)
196
- end
119
+ wrap_attachable(attachable).filename
197
120
  end
198
- # rubocop:enable Metrics/MethodLength
199
121
 
200
- # Raise the same Rails error for not-implemented file representations.
201
- def raise_rails_like_error(attachable)
202
- raise(
203
- ArgumentError,
204
- "Could not find or build blob: expected attachable, " \
205
- "got #{attachable.inspect}"
206
- )
122
+ def wrap_attachable(attachable)
123
+ ASVAttachableAdapter.wrap(attachable, file_supported: supports_file_attachment?)
207
124
  end
208
125
 
209
126
  # Check if the current Rails version supports File or Pathname attachment
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../asv_attachable_adapter"
4
+
3
5
  module ActiveStorageValidations
4
6
  module ASVErrorable
5
7
  extend ActiveSupport::Concern
@@ -17,9 +19,9 @@ module ActiveStorageValidations
17
19
  curated_options.merge(active_storage_validations_options)
18
20
  end
19
21
 
22
+ # Every offending file gets its own error, so a has_many_attached relation
23
+ # reports each invalid filename rather than only the first one.
20
24
  def add_error(record, attribute, error_type, **errors_options)
21
- return if record.errors.added?(attribute, error_type)
22
-
23
25
  # You can read https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-add
24
26
  # to better understand how Rails model errors work
25
27
  record.errors.add(attribute, error_type, **errors_options)
@@ -30,12 +32,11 @@ module ActiveStorageValidations
30
32
  def get_filename(file)
31
33
  return nil unless file
32
34
 
33
- case file
34
- when ActiveStorage::Attached, ActiveStorage::Attachment then file.blob&.filename&.to_s
35
- when ActiveStorage::Blob then file.filename
36
- when String then ActiveStorage::Blob.find_signed!(file)&.filename
37
- when Hash then file[:filename]
38
- end.to_s
35
+ filename_from(file)&.to_s.presence
36
+ end
37
+
38
+ def filename_from(file)
39
+ ASVAttachableAdapter.filename_for(file)
39
40
  end
40
41
  end
41
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- VERSION = "4.0.0"
4
+ VERSION = "4.1.1"
5
5
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
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_symbolizable"
8
+
9
+ module ActiveStorageValidations
10
+ class WithAudioValidator < ActiveModel::EachValidator # :nodoc
11
+ include ASVActiveStorageable
12
+ include ASVAnalyzable
13
+ include ASVAttachable
14
+ include ASVErrorable
15
+ include ASVSymbolizable
16
+
17
+ ERROR_TYPES = %i[audio_missing audio_present].freeze
18
+ METADATA_KEYS = %i[audio].freeze
19
+
20
+ def validate_each(record, attribute, _value)
21
+ return if no_attachments?(record, attribute)
22
+
23
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
24
+ end
25
+
26
+ private
27
+
28
+ def is_valid?(record, attribute, attachable, metadata)
29
+ expected_audio = options.fetch(:with, true)
30
+ return if audio_track?(metadata) == expected_audio
31
+
32
+ errors_options = initialize_error_options(options, attachable)
33
+ error_type = expected_audio ? :audio_missing : :audio_present
34
+ add_error(record, attribute, error_type, **errors_options)
35
+ end
36
+
37
+ # Files the analyzer cannot inspect for audio streams (images, pdfs) have no
38
+ # audio metadata, or a memoized unavailable value. Both mean no audio track.
39
+ def audio_track?(metadata)
40
+ metadata&.fetch(:audio, false) == true
41
+ end
42
+ end
43
+ end
@@ -41,6 +41,7 @@ require "active_storage_validations/content_type_validator"
41
41
  require "active_storage_validations/limit_validator"
42
42
  require "active_storage_validations/dimension_validator"
43
43
  require "active_storage_validations/duration_validator"
44
+ require "active_storage_validations/with_audio_validator"
44
45
  require "active_storage_validations/aspect_ratio_validator"
45
46
  require "active_storage_validations/processable_file_validator"
46
47
  require "active_storage_validations/size_validator"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage_validations
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Kasyanchuk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-02 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -233,6 +233,7 @@ files:
233
233
  - lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb
234
234
  - lib/active_storage_validations/analyzer/video_analyzer.rb
235
235
  - lib/active_storage_validations/aspect_ratio_validator.rb
236
+ - lib/active_storage_validations/asv_attachable_adapter.rb
236
237
  - lib/active_storage_validations/attached_validator.rb
237
238
  - lib/active_storage_validations/base_comparison_validator.rb
238
239
  - lib/active_storage_validations/content_type_validator.rb
@@ -265,6 +266,7 @@ files:
265
266
  - lib/active_storage_validations/matchers/shared/asv_validatable.rb
266
267
  - lib/active_storage_validations/matchers/size_validator_matcher.rb
267
268
  - lib/active_storage_validations/matchers/total_size_validator_matcher.rb
269
+ - lib/active_storage_validations/matchers/with_audio_validator_matcher.rb
268
270
  - lib/active_storage_validations/pages_validator.rb
269
271
  - lib/active_storage_validations/processable_file_validator.rb
270
272
  - lib/active_storage_validations/railtie.rb
@@ -279,6 +281,7 @@ files:
279
281
  - lib/active_storage_validations/size_validator.rb
280
282
  - lib/active_storage_validations/total_size_validator.rb
281
283
  - lib/active_storage_validations/version.rb
284
+ - lib/active_storage_validations/with_audio_validator.rb
282
285
  homepage: https://github.com/igorkasyanchuk/active_storage_validations
283
286
  licenses:
284
287
  - MIT