active_storage_validations 4.1.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -9
  3. data/config/locales/sv.yml +2 -2
  4. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +7 -1
  5. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +0 -2
  6. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +0 -2
  7. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +3 -0
  8. data/lib/active_storage_validations/analyzer/image_analyzer.rb +16 -6
  9. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +11 -2
  10. data/lib/active_storage_validations/analyzer.rb +2 -35
  11. data/lib/active_storage_validations/aspect_ratio_validator.rb +21 -15
  12. data/lib/active_storage_validations/asv_attachable_adapter.rb +221 -0
  13. data/lib/active_storage_validations/content_type_validator.rb +40 -64
  14. data/lib/active_storage_validations/duration_validator.rb +3 -1
  15. data/lib/active_storage_validations/engine.rb +3 -0
  16. data/lib/active_storage_validations/extensors/asv_marcelable.rb +5 -5
  17. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -4
  18. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +2 -1
  19. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +1 -0
  20. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +1 -1
  21. data/lib/active_storage_validations/matchers.rb +21 -10
  22. data/lib/active_storage_validations/shared/asv_analyzable.rb +51 -8
  23. data/lib/active_storage_validations/shared/asv_attachable.rb +14 -97
  24. data/lib/active_storage_validations/shared/asv_errorable.rb +9 -8
  25. data/lib/active_storage_validations/version.rb +1 -1
  26. data/lib/active_storage_validations/with_audio_validator.rb +7 -1
  27. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24d8643d1f970252124d4429ca9bc15b222ef203cdcae93427a0860c7190c370
4
- data.tar.gz: 62a74e517b40b32dba39c0529f0d4e4529d615a11decdebf8ec338505c082435
3
+ metadata.gz: 5a6bd490e0f58921da42838a5df629556c0e9f84a01c114b8772ac195afea917
4
+ data.tar.gz: 397f02aeea00242f94af79a422d37dbcd90c1e07946107bf08f2dccf1da0c793
5
5
  SHA512:
6
- metadata.gz: c8fa701638b13eea05351e5a76847d144e93ec83b4e14a8d5b5e23ae71944eabd092704c723fcb81e9c44a3fb909ab6431be680390ee414f13002af671792ca1
7
- data.tar.gz: 82af6984104d830cd7eda7ffdb557f3bb997d8bb59fbef6edc85e0830c3a4397209d4ab31f6427f5945fe392935fefe7c49dc6cabd87c840a34592e8b827f300
6
+ metadata.gz: ffc3c1b5b9a722dbc2b33d57560fdc41a2efb5ec68095883e15d251024e073f5d34342a2c0f10515794bd1ea6aeed1de593ca0e345af9cfb130d2d424543bf9a
7
+ data.tar.gz: aaf819d38fd5e91de9eeb68248b9d8e4f0faf733a9b5773a682819bb19933b6e740336315842914a1962b9ef2c39f2b9637fd3c481e584dd9c4f566237f0ace5
data/README.md CHANGED
@@ -114,8 +114,21 @@ end
114
114
  # event = ActiveSupport::Notifications::Event.new(*args)
115
115
  # Rails.logger.warn("[ASV] command timeout: #{event.payload}")
116
116
  # end
117
+
118
+ # Optional: monitor how long metadata / content type analysis takes
119
+ # ActiveSupport::Notifications.subscribe("analyze.active_storage_validations") do |*args|
120
+ # event = ActiveSupport::Notifications::Event.new(*args)
121
+ # Rails.logger.info("[ASV] analyzed: #{event.payload}")
122
+ # end
117
123
  ```
118
124
 
125
+ The gem emits two `ActiveSupport::Notifications` events:
126
+
127
+ | Event | Payload | Emitted |
128
+ |---|---|---|
129
+ | `analyze.active_storage_validations` | `analyzer`, `command`, `timeout`, `timed_out`, `duration` | Every time an analyzer command runs (i.e. on a cache miss) |
130
+ | `timeout.active_storage_validations` | `analyzer`, `command`, `timeout` | Only when a command exceeds its deadline |
131
+
119
132
  `command_timeout` bounds metadata analysis used by `dimension`, `aspect_ratio`, `duration`, `with_audio`, `pages`, `processable_file`, and `content_type` (with `spoofing_protection`). When a command times out, analysis fails closed and the validator adds its usual error (`file_not_processable` / `media_metadata_missing` / `audio_missing` / content-type errors) — there is no separate timeout error message.
120
133
 
121
134
  The 10s default is enough for typical uploads. Raise it (or set `nil`) if you analyze very large videos/PDFs, especially on slow or network storage — otherwise those files can start failing validation after upgrade. See [upgrade to 4.x](docs/upgrade_to_4.md#analyzer-command-timeout).
@@ -141,7 +154,7 @@ Notes:
141
154
  - [Total size](#total-size): validates total file size for several files
142
155
  - [Dimension](#dimension): validates image / video dimensions
143
156
  - [Duration](#duration): validates video / audio duration
144
- - [With audio](#with-audio): validates whether a video contains an audio track
157
+ - [With audio](#with-audio): validates whether a video / audio file contains an audio track
145
158
  - [Aspect ratio](#aspect-ratio): validates image / video aspect ratio
146
159
  - [Processable file](#processable-file): validates if a file can be processed
147
160
  - [Pages](#pages): validates pdf number of pages
@@ -163,6 +176,8 @@ Some validators rely on an expensive operation (metadata analysis and content ty
163
176
 
164
177
  As stated in the Rails documentation: "Blobs are intended to be immutable in so far as their reference to a specific file goes". We based our performance optimization on the same assumption, so if you do not follow it, the gem will not work as expected.
165
178
 
179
+ This also covers metadata the analyzer cannot extract from a given file (e.g. a `duration` validation on an image): the gem remembers that the value is unavailable, so the validation keeps failing with `media_metadata_missing` but the file is only analyzed once. Files that yield no metadata at all — missing command-line tool, [timed out](#configuration) command, unreadable file — are not remembered and get analyzed again on the next validation.
180
+
166
181
  ---
167
182
 
168
183
  ### Attached
@@ -319,7 +334,8 @@ ActiveStorageValidations.configure { |config| config.infer_file_field_accept = f
319
334
 
320
335
  Notes:
321
336
  - Only broad MIME-type regexes of the form `/\Aimage\/.*\z/` (or `video` / `audio` / etc.) are inferred, as `image/*`
322
- - Other regexes (e.g. `/\Aimage\/(png|gif)\z/`) and Proc / dynamic `content_type` options are skipped, since they cannot be reliably represented in `accept`
337
+ - Other regexes (e.g. `/\Aimage\/(png|gif)\z/`) are skipped, since they cannot be reliably represented in `accept`
338
+ - Proc / dynamic `content_type` options are skipped: they are not called while the form renders, because their result can depend on record state that is only set on submit, and a raising proc would break the whole view for what is merely a frontend hint. Backend validation is unchanged; pass a custom `accept:` on those fields if you want the picker filtered
323
339
  - Conditional validators (`if:` / `unless:`) are not evaluated: their content types are always included in `accept`, even when the condition would skip the validator for that record. Backend validation is unchanged; use `infer_accept: false` (or a custom `accept:`) if the picker must match the active conditions
324
340
 
325
341
  #### Content type shorthands
@@ -364,7 +380,7 @@ Spoofing protection compares the declared Active Storage content type to a type
364
380
 
365
381
  Neither backend fully parses the file. They do **not** load the whole file into RAM. For already-persisted blobs (e.g. remote storage), the analyzer still downloads the blob to a local tempfile before sniffing. That download is streamed in chunks to disk, but it can still be costly for very large files. Local path uploads are analyzed in place.
366
382
 
367
- Detected types are cached on the blob as `asv_content_type` + `asv_content_type_backend`. Switching backend re-analyzes. Legacy blobs that only have `asv_content_type` (no backend key) are treated as `:file` and keep using the cache — they are not re-analyzed.
383
+ Detected types are cached on the blob as `asv_content_type` + `asv_content_type_backend`. Switching backend re-analyzes. Legacy blobs that only have `asv_content_type` (no backend key) are treated as `:file` and keep using the cache — they are not re-analyzed. New (unsaved) blobs keep `asv_*` in memory until the record is saved; already-persisted blobs write the cache immediately so it survives `reload`.
368
384
 
369
385
  Sniffers will not always return the exact same MIME as Active Storage (AS uses first ~4kb + filename + extension). Close parent types are accepted via `Marcel::TYPE_PARENTS` (e.g. `video/x-ms-wmv` vs `video/x-ms-asf`).
370
386
 
@@ -648,7 +664,7 @@ The `duration` validator error messages expose 4 values that you can use:
648
664
 
649
665
  ### With audio
650
666
 
651
- Validates whether attached video files contain an audio track.
667
+ Validates whether attached video / audio files contain an audio track.
652
668
  (be sure to have the right dependencies installed as mentioned in [Using video and audio metadata validators](#using-video-and-audio-metadata-validators))
653
669
 
654
670
  #### Options
@@ -671,7 +687,9 @@ class User < ApplicationRecord
671
687
  end
672
688
  ```
673
689
 
674
- Rails treats a bare `with_audio: false` as a disabled validator. Use the hash form `with_audio: { with: false }` to reject videos that contain audio.
690
+ Rails treats a bare `with_audio: false` as a disabled validator. Use the hash form `with_audio: { with: false }` to reject files that contain audio.
691
+
692
+ Files that cannot contain an audio stream at all (images, pdfs) are treated as having no audio track: they fail `with_audio: true` and pass `with_audio: { with: false }`.
675
693
 
676
694
  #### Error messages (I18n)
677
695
 
@@ -958,9 +976,6 @@ All matchers can currently be customized with these options:
958
976
 
959
977
  ```ruby
960
978
  describe User do
961
- # :allow_blank
962
- it { is_expected.to validate_attached_of(:avatar).allow_blank }
963
-
964
979
  # :on
965
980
  it { is_expected.to validate_attached_of(:avatar).on(:update) }
966
981
  it { is_expected.to validate_attached_of(:avatar).on(%i[update custom]) }
@@ -982,7 +997,7 @@ end
982
997
  ### Minitest
983
998
 
984
999
  #### Setup
985
- To use the matchers, make sure you have the [shoulda-context](https://github.com/thoughtbot/shoulda-context) gem up and running.
1000
+ To use the matchers, make sure you have the [shoulda-context](https://github.com/thoughtbot/shoulda-context) gem up and running. No extra mock library is required (`minitest-mock` is not needed on Minitest 6).
986
1001
 
987
1002
  You need to require the matchers:
988
1003
 
@@ -52,8 +52,8 @@ sv:
52
52
  aspect_ratio_not_square: "måste vara kvadratisk (nuvarande fil är %{width}x%{height} pixlar)"
53
53
  aspect_ratio_not_portrait: "måste vara porträtt (nuvarande fil är %{width}x%{height} pixlar)"
54
54
  aspect_ratio_not_landscape: "måste vara landskap (nuvarande fil är %{width}x%{height} pixlar)"
55
- aspect_ratio_not_x_y: "måste vara% { authorized_aspect_ratios } (nuvarande fil är %{width}x%{height} pixlar)"
56
- aspect_ratio_invalid: "har ett ogiltigt bildförhållande (giltiga bildförhållanden är %{förväntat_aspect_ratios})"
55
+ aspect_ratio_not_x_y: "måste vara %{authorized_aspect_ratios} (nuvarande fil är %{width}x%{height} pixlar)"
56
+ aspect_ratio_invalid: "har ett ogiltigt bildförhållande (giltiga bildförhållanden är %{authorized_aspect_ratios})"
57
57
  file_not_processable: "identifieras inte som en giltig mediefil"
58
58
  audio_missing: "måste innehålla ett ljudspår"
59
59
  audio_present: "får inte innehålla ett ljudspår"
@@ -10,12 +10,13 @@ module ActiveStorageValidations
10
10
  # * Duration (seconds)
11
11
  # * Bit rate (bits/s)
12
12
  # * Sample rate (hertz)
13
+ # * Audio (true if file has an audio channel, false if not)
13
14
  # * Tags (internal metadata)
14
15
  #
15
16
  # Example:
16
17
  #
17
18
  # ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable).metadata
18
- # # => { duration: 5.0, bit_rate: 320340, sample_rate: 44100, tags: { encoder: "Lavc57.64", ... } }
19
+ # # => { duration: 5.0, bit_rate: 320340, sample_rate: 44100, audio: true, tags: { encoder: "Lavc57.64", ... } }
19
20
  #
20
21
  # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
21
22
  class Analyzer::AudioAnalyzer < Analyzer
@@ -27,6 +28,7 @@ module ActiveStorageValidations
27
28
  duration: duration,
28
29
  bit_rate: bit_rate,
29
30
  sample_rate: sample_rate,
31
+ audio: audio?,
30
32
  tags: tags
31
33
  }.compact
32
34
  end
@@ -34,6 +36,10 @@ module ActiveStorageValidations
34
36
 
35
37
  private
36
38
 
39
+ def audio?
40
+ audio_stream.present?
41
+ end
42
+
37
43
  def duration
38
44
  duration = audio_stream["duration"]
39
45
  Float(duration).round(1) if duration
@@ -5,8 +5,6 @@ module ActiveStorageValidations
5
5
  #
6
6
  # Override the binary path with +ActiveStorage.paths[:file]+.
7
7
  class Analyzer::ContentTypeAnalyzer::File < Analyzer::ContentTypeAnalyzer
8
- class CommandLineToolNotInstalledError < StandardError; end
9
-
10
8
  private
11
9
 
12
10
  def backend
@@ -7,8 +7,6 @@ module ActiveStorageValidations
7
7
  #
8
8
  # Override the binary path with +ActiveStorage.paths[:magika]+.
9
9
  class Analyzer::ContentTypeAnalyzer::Magika < Analyzer::ContentTypeAnalyzer
10
- class CommandLineToolNotInstalledError < StandardError; end
11
-
12
10
  private
13
11
 
14
12
  def backend
@@ -12,6 +12,9 @@ module ActiveStorageValidations
12
12
  # ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::Magika.new(attachable).content_type
13
13
  # # => { content_type: "image/png", content_type_backend: "magika" }
14
14
  class Analyzer::ContentTypeAnalyzer < Analyzer
15
+ # Shared by File and Magika backends so consumers can rescue either missing CLI.
16
+ class CommandLineToolNotInstalledError < StandardError; end
17
+
15
18
  def content_type
16
19
  read_media do |media|
17
20
  {
@@ -12,7 +12,15 @@ module ActiveStorageValidations
12
12
  # ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
13
13
  # # => { width: 4104, height: 2736 }
14
14
  class Analyzer::ImageAnalyzer < Analyzer
15
- @@supported_analyzers = {}
15
+ class << self
16
+ def reset_supported_analyzers_cache!
17
+ @supported_analyzers = {}
18
+ end
19
+
20
+ def supported_analyzers
21
+ @supported_analyzers ||= {}
22
+ end
23
+ end
16
24
 
17
25
  def metadata
18
26
  return {} unless analyzer_supported?
@@ -28,12 +36,14 @@ module ActiveStorageValidations
28
36
 
29
37
  private
30
38
 
39
+ # Cache by class, not instance: a new analyzer is built per attachable, so
40
+ # an instance-keyed hash never hits and retains every attachable for the
41
+ # process lifetime.
31
42
  def analyzer_supported?
32
- if @@supported_analyzers.key?(self)
33
- @@supported_analyzers.fetch(self)
34
- else
35
- @@supported_analyzers[self] = supported?
36
- end
43
+ cache = Analyzer::ImageAnalyzer.supported_analyzers
44
+ return cache[self.class] if cache.key?(self.class)
45
+
46
+ cache[self.class] = supported?
37
47
  end
38
48
 
39
49
  # Override this method in a concrete subclass. Have it return true if the image is rotated.
@@ -16,6 +16,11 @@ module ActiveStorageValidations
16
16
  #
17
17
  # This analyzer requires the {poppler}[https://pdf2image.readthedocs.io/en/latest/installation.html] system library, which is not provided by \Rails.
18
18
  class Analyzer::PdfAnalyzer < Analyzer
19
+ # pdfinfo reports e.g. "595.276 x 841.89 pts (A4)". Capture the full
20
+ # integer or decimal so extra fractional digits are not split into a
21
+ # second number (which would make height 8 for A4).
22
+ PAGE_SIZE_NUMBER = /\d+(?:\.\d+)?/
23
+
19
24
  def metadata
20
25
  read_media do |media|
21
26
  {
@@ -69,11 +74,15 @@ module ActiveStorageValidations
69
74
  end
70
75
 
71
76
  def width
72
- @media["page_size"].scan(/\d+\.?\d?/)[0].to_i
77
+ page_size_in_points[0]
73
78
  end
74
79
 
75
80
  def height
76
- @media["page_size"].scan(/\d+\.?\d?/)[1].to_i
81
+ page_size_in_points[1]
82
+ end
83
+
84
+ def page_size_in_points
85
+ @media["page_size"].to_s.scan(PAGE_SIZE_NUMBER).map(&:to_i)
77
86
  end
78
87
 
79
88
  def pages
@@ -40,45 +40,12 @@ module ActiveStorageValidations
40
40
  raise NotImplementedError
41
41
  end
42
42
 
43
- # rubocop:disable Metrics/MethodLength
44
43
  def media(tempfile)
45
- @media ||= case @attachable
46
- when ActiveStorage::Blob, String
47
- blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
48
- media_from_tempfile_path(tempfile, blob)
49
- when Hash
50
- io = @attachable[:io]
51
- if io.is_a?(StringIO)
52
- media_from_tempfile_path(tempfile, io)
53
- else
54
- File.open(io) do |file|
55
- media_from_path(file.path)
56
- end
57
- end
58
- when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
59
- media_from_path(@attachable.path)
60
- when File
61
- supports_file_attachment? ? media_from_path(@attachable.path) : raise_rails_like_error(@attachable)
62
- when Pathname
63
- supports_pathname_attachment? ? media_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
64
- else
65
- raise_rails_like_error(@attachable)
44
+ @media ||= wrap_attachable(@attachable).with_media_path(tempfile) do |path|
45
+ media_from_path(path)
66
46
  end
67
47
  end
68
48
 
69
- def media_from_tempfile_path(tempfile, file_representation)
70
- if file_representation.is_a?(ActiveStorage::Blob)
71
- file_representation.download { |chunk| tempfile.write(chunk) }
72
- else
73
- IO.copy_stream(file_representation, tempfile)
74
- file_representation.rewind
75
- end
76
-
77
- tempfile.flush
78
- tempfile.rewind
79
- media_from_path(tempfile.path)
80
- end
81
-
82
49
  # Override this method in a concrete subclass. Have it return a media object.
83
50
  def media_from_path(path)
84
51
  raise NotImplementedError
@@ -18,7 +18,7 @@ module ActiveStorageValidations
18
18
 
19
19
  AVAILABLE_CHECKS = %i[with in].freeze
20
20
  NAMED_ASPECT_RATIOS = %i[square portrait landscape].freeze
21
- ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/.freeze
21
+ ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/
22
22
  ERROR_TYPES = %i[
23
23
  aspect_ratio_not_square
24
24
  aspect_ratio_not_portrait
@@ -27,7 +27,7 @@ module ActiveStorageValidations
27
27
  aspect_ratio_invalid
28
28
  media_metadata_missing
29
29
  ].freeze
30
- PRECISION = 3.freeze
30
+ PRECISION = 3
31
31
  METADATA_KEYS = %i[width height].freeze
32
32
 
33
33
  def check_validity!
@@ -38,22 +38,28 @@ module ActiveStorageValidations
38
38
  def validate_each(record, attribute, _value)
39
39
  return if no_attachments?(record, attribute)
40
40
 
41
- flat_options = set_flat_options(record)
42
- @authorized_aspect_ratios = authorized_aspect_ratios_from_options(flat_options).compact
43
- return if @authorized_aspect_ratios.empty?
41
+ return if authorized_aspect_ratios(record).empty?
44
42
 
45
43
  validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
46
44
  end
47
45
 
48
46
  private
49
47
 
48
+ # Recomputed for every attachable rather than memoized on the validator:
49
+ # Active Model reuses one validator instance across records and threads, so
50
+ # per-validation state cannot live in an instance variable.
51
+ def authorized_aspect_ratios(record)
52
+ authorized_aspect_ratios_from_options(set_flat_options(record)).compact
53
+ end
54
+
50
55
  def is_valid?(record, attribute, attachable, metadata)
51
56
  media_metadata_present?(record, attribute, attachable, metadata) &&
52
57
  authorized_aspect_ratio?(record, attribute, attachable, metadata)
53
58
  end
54
59
 
55
60
  def authorized_aspect_ratio?(record, attribute, attachable, metadata)
56
- attachable_aspect_ratio_is_authorized = @authorized_aspect_ratios.any? do |authorized_aspect_ratio|
61
+ aspect_ratios = authorized_aspect_ratios(record)
62
+ attachable_aspect_ratio_is_authorized = aspect_ratios.any? do |authorized_aspect_ratio|
57
63
  case authorized_aspect_ratio
58
64
  when :square then valid_square_aspect_ratio?(metadata)
59
65
  when :portrait then valid_portrait_aspect_ratio?(metadata)
@@ -64,18 +70,18 @@ module ActiveStorageValidations
64
70
 
65
71
  return true if attachable_aspect_ratio_is_authorized
66
72
 
67
- errors_options = initialize_and_populate_error_options(options, attachable)
73
+ errors_options = initialize_and_populate_error_options(options, attachable, aspect_ratios)
68
74
  errors_options[:width] = metadata[:width]
69
75
  errors_options[:height] = metadata[:height]
70
- error_type = aspect_ratio_error_mapping
76
+ error_type = aspect_ratio_error_mapping(aspect_ratios)
71
77
  add_error(record, attribute, error_type, **errors_options)
72
78
  false
73
79
  end
74
80
 
75
- def aspect_ratio_error_mapping
76
- return :aspect_ratio_invalid if @authorized_aspect_ratios.many?
81
+ def aspect_ratio_error_mapping(aspect_ratios)
82
+ return :aspect_ratio_invalid if aspect_ratios.many?
77
83
 
78
- aspect_ratio = @authorized_aspect_ratios.first
84
+ aspect_ratio = aspect_ratios.first
79
85
  NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
80
86
  end
81
87
 
@@ -86,9 +92,9 @@ module ActiveStorageValidations
86
92
  false
87
93
  end
88
94
 
89
- def initialize_and_populate_error_options(options, attachable)
95
+ def initialize_and_populate_error_options(options, attachable, aspect_ratios)
90
96
  errors_options = initialize_error_options(options, attachable)
91
- errors_options[:authorized_aspect_ratios] = string_aspect_ratios
97
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios(aspect_ratios)
92
98
  errors_options
93
99
  end
94
100
 
@@ -140,8 +146,8 @@ module ActiveStorageValidations
140
146
  (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in]))
141
147
  end
142
148
 
143
- def string_aspect_ratios
144
- @authorized_aspect_ratios.map do |aspect_ratio|
149
+ def string_aspect_ratios(aspect_ratios)
150
+ aspect_ratios.map do |aspect_ratio|
145
151
  if NAMED_ASPECT_RATIOS.include?(aspect_ratio)
146
152
  aspect_ratio
147
153
  else
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # Wraps a Rails attachable so callers do not repeat the type-dispatch case tree.
5
+ #
6
+ # Must not be named +Attachable+: ActiveStorageValidations is included into
7
+ # Active Record, so +include Attachable+ in an app model under this namespace
8
+ # would resolve to this class instead of the app's concern.
9
+ #
10
+ # Supported representations: ActiveStorage::Blob, ActionDispatch::Http::UploadedFile,
11
+ # Rack::Test::UploadedFile, Hash, File, Pathname, and a signed blob id (String).
12
+ class ASVAttachableAdapter
13
+ def self.wrap(attachable, file_supported:)
14
+ handler_class_for(attachable).new(attachable, file_supported: file_supported)
15
+ end
16
+
17
+ # Filename for error options. Unknown types return nil (never raise).
18
+ # File / Pathname are always accepted here so a validation error can name
19
+ # the file even on Rails versions that cannot attach them.
20
+ def self.filename_for(file)
21
+ case file
22
+ when ActiveStorage::Attached, ActiveStorage::Attachment
23
+ file.blob&.filename
24
+ else
25
+ return unless known?(file)
26
+
27
+ wrap(file, file_supported: true).filename
28
+ end
29
+ end
30
+
31
+ def self.known?(attachable)
32
+ handler_class_for(attachable) != Unsupported
33
+ end
34
+ private_class_method :known?
35
+
36
+ def self.handler_class_for(attachable)
37
+ case attachable
38
+ when ActiveStorage::Blob then Blob
39
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile then Uploaded
40
+ when String then SignedId
41
+ when Hash then HashIo
42
+ when File then FileLike
43
+ when Pathname then Path
44
+ else Unsupported
45
+ end
46
+ end
47
+ private_class_method :handler_class_for
48
+
49
+ class Base
50
+ def initialize(attachable, file_supported:)
51
+ @attachable = attachable
52
+ @file_supported = file_supported
53
+ end
54
+
55
+ def content_type
56
+ raise NotImplementedError
57
+ end
58
+
59
+ def filename
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def read(max_byte_size: nil)
64
+ raise NotImplementedError
65
+ end
66
+
67
+ def rewind
68
+ end
69
+
70
+ def with_media_path(_tempfile)
71
+ raise NotImplementedError
72
+ end
73
+
74
+ private
75
+
76
+ def raise_unsupported
77
+ raise ArgumentError,
78
+ "Could not find or build blob: expected attachable, " \
79
+ "got #{@attachable.inspect}"
80
+ end
81
+
82
+ def read_from(io, max_byte_size)
83
+ max_byte_size ? io.read(max_byte_size) : io.read
84
+ end
85
+
86
+ def copy_to_tempfile(tempfile, source)
87
+ if source.is_a?(ActiveStorage::Blob)
88
+ source.download { |chunk| tempfile.write(chunk) }
89
+ else
90
+ IO.copy_stream(source, tempfile)
91
+ source.rewind
92
+ end
93
+
94
+ tempfile.flush
95
+ tempfile.rewind
96
+ yield tempfile.path
97
+ end
98
+
99
+ def marcel_content_type
100
+ Marcel::MimeType.for(name: filename.to_s)
101
+ end
102
+ end
103
+
104
+ class Blob < Base
105
+ def content_type
106
+ @attachable.content_type
107
+ end
108
+
109
+ def filename
110
+ @attachable.filename
111
+ end
112
+
113
+ def read(max_byte_size: nil)
114
+ max_byte_size ? @attachable.download_chunk(0...max_byte_size) : @attachable.download
115
+ end
116
+
117
+ def with_media_path(tempfile, &block)
118
+ copy_to_tempfile(tempfile, @attachable, &block)
119
+ end
120
+ end
121
+
122
+ class SignedId < Blob
123
+ def initialize(attachable, file_supported:)
124
+ super(ActiveStorage::Blob.find_signed!(attachable), file_supported: file_supported)
125
+ end
126
+ end
127
+
128
+ class Uploaded < Base
129
+ def content_type
130
+ @attachable.content_type
131
+ end
132
+
133
+ def filename
134
+ @attachable.original_filename
135
+ end
136
+
137
+ def read(max_byte_size: nil)
138
+ read_from(@attachable, max_byte_size)
139
+ end
140
+
141
+ def rewind
142
+ @attachable.rewind
143
+ end
144
+
145
+ def with_media_path(_tempfile)
146
+ yield @attachable.path
147
+ end
148
+ end
149
+
150
+ class HashIo < Base
151
+ def content_type
152
+ @attachable[:content_type]
153
+ end
154
+
155
+ def filename
156
+ @attachable[:filename]
157
+ end
158
+
159
+ def read(max_byte_size: nil)
160
+ read_from(@attachable[:io], max_byte_size)
161
+ end
162
+
163
+ def rewind
164
+ @attachable[:io].rewind
165
+ end
166
+
167
+ def with_media_path(tempfile, &block)
168
+ io = @attachable[:io]
169
+ if io.is_a?(StringIO)
170
+ copy_to_tempfile(tempfile, io, &block)
171
+ else
172
+ File.open(io) { |file| yield file.path }
173
+ end
174
+ end
175
+ end
176
+
177
+ class FileLike < Base
178
+ def initialize(attachable, file_supported:)
179
+ super
180
+ raise_unsupported unless file_supported
181
+ end
182
+
183
+ def content_type
184
+ marcel_content_type
185
+ end
186
+
187
+ def filename
188
+ File.basename(@attachable)
189
+ end
190
+
191
+ def read(max_byte_size: nil)
192
+ read_from(@attachable, max_byte_size)
193
+ end
194
+
195
+ def rewind
196
+ @attachable.rewind
197
+ end
198
+
199
+ def with_media_path(_tempfile)
200
+ yield @attachable.path
201
+ end
202
+ end
203
+
204
+ class Path < FileLike
205
+ def rewind
206
+ File.open(@attachable) { |file| file.rewind }
207
+ end
208
+
209
+ def with_media_path(_tempfile)
210
+ yield @attachable.to_s
211
+ end
212
+ end
213
+
214
+ class Unsupported < Base
215
+ def initialize(attachable, file_supported:)
216
+ super
217
+ raise_unsupported
218
+ end
219
+ end
220
+ end
221
+ end
@@ -24,6 +24,11 @@ module ActiveStorageValidations
24
24
  ].freeze
25
25
  METADATA_KEYS = %i[content_type].freeze
26
26
 
27
+ # State for a single attachable check. Active Model builds one validator per
28
+ # class and reuses it for every record and every thread, so this must travel
29
+ # through arguments rather than instance variables.
30
+ Context = Struct.new(:authorized_content_types, :content_type, :filename)
31
+
27
32
  def check_validity!
28
33
  ensure_exactly_one_validator_option
29
34
  ensure_content_types_validity
@@ -33,12 +38,12 @@ module ActiveStorageValidations
33
38
  def validate_each(record, attribute, _value)
34
39
  return if no_attachments?(record, attribute)
35
40
 
36
- @authorized_content_types = authorized_content_types_from_options(record)
37
- return if @authorized_content_types.empty?
41
+ authorized_content_types = authorized_content_types_from_options(record)
42
+ return if authorized_content_types.empty?
38
43
 
39
44
  attachables_and_blobs(record, attribute).each do |attachable, blob|
40
- set_attachable_cached_values(blob)
41
- is_valid?(record, attribute, attachable, blob)
45
+ context = Context.new(authorized_content_types, blob.content_type, blob.filename.to_s)
46
+ is_valid?(record, attribute, attachable, blob, context)
42
47
  end
43
48
  end
44
49
 
@@ -55,76 +60,47 @@ module ActiveStorageValidations
55
60
  end
56
61
  end
57
62
 
58
- def set_attachable_cached_values(blob)
59
- @attachable_content_type = blob.content_type
60
- @attachable_filename = blob.filename.to_s
61
- end
62
-
63
63
  # Check if the provided content_type is authorized and not spoofed against
64
64
  # the file io.
65
- def is_valid?(record, attribute, attachable, blob)
66
- authorized_content_type?(record, attribute, attachable) &&
67
- not_spoofing_content_type?(record, attribute, attachable, blob)
65
+ def is_valid?(record, attribute, attachable, blob, context)
66
+ authorized_content_type?(record, attribute, attachable, context) &&
67
+ not_spoofing_content_type?(record, attribute, attachable, blob, context)
68
68
  end
69
69
 
70
- # Dead code that we keep here for some time, maybe we will find a solution
71
- # to this check later? (November 2024)
72
- #
73
- # We do not perform any validations against the extension because it is an
74
- # unreliable source of truth. For example, a `.csv` file could have its
75
- # `text/csv` content_type changed to `application/vnd.ms-excel` because
76
- # it had been opened by Excel at some point, making the file extension vs
77
- # file content_type check invalid.
78
- # def extension_matches_content_type?(record, attribute, attachable)
79
- # return true if !@attachable_filename || !@attachable_content_type
80
-
81
- # extension = @attachable_filename.split('.').last
82
- # possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
83
- # return true if possible_extensions && extension.downcase.in?(possible_extensions)
84
-
85
- # errors_options = initialize_and_populate_error_options(options, attachable)
86
- # add_error(record, attribute, ERROR_TYPES.first, **errors_options)
87
- # false
88
- # end
89
-
90
- def authorized_content_type?(record, attribute, attachable)
91
- attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
70
+ def authorized_content_type?(record, attribute, attachable, context)
71
+ attachable_content_type_is_authorized = context.authorized_content_types.any? do |authorized_content_type|
92
72
  case authorized_content_type
93
- when String then authorized_content_type == marcel_attachable_content_type(attachable)
94
- when Regexp then authorized_content_type.match?(marcel_attachable_content_type(attachable).to_s)
73
+ when String then authorized_content_type == marcel_attachable_content_type(context)
74
+ when Regexp then authorized_content_type.match?(marcel_attachable_content_type(context).to_s)
95
75
  end
96
76
  end
97
77
 
98
78
  return true if attachable_content_type_is_authorized
99
79
 
100
- add_content_type_invalid_error(record, attribute, attachable)
80
+ add_content_type_invalid_error(record, attribute, attachable, context)
101
81
  end
102
82
 
103
- def marcel_attachable_content_type(attachable)
104
- Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
83
+ def marcel_attachable_content_type(context)
84
+ Marcel::MimeType.for(declared_type: context.content_type, name: context.filename)
105
85
  end
106
86
 
107
- def not_spoofing_content_type?(record, attribute, attachable, blob)
87
+ def not_spoofing_content_type?(record, attribute, attachable, blob, context)
108
88
  return true unless enable_spoofing_protection?
109
89
 
110
- @detected_content_type = begin
90
+ detected_content_type = begin
111
91
  metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
112
92
  rescue ActiveStorage::FileNotFoundError
113
93
  add_attachment_missing_error(record, attribute, attachable)
114
94
  return false
115
95
  end
116
96
 
117
- if attachable_content_type_vs_detected_content_type_mismatch?
118
- add_content_type_spoofed_error(record, attribute, attachable, @detected_content_type)
97
+ if content_type_mismatch?(context.content_type, detected_content_type)
98
+ add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
119
99
  else
120
100
  true
121
101
  end
122
102
  end
123
103
 
124
- def disable_spoofing_protection?
125
- !enable_spoofing_protection?
126
- end
127
-
128
104
  def enable_spoofing_protection?
129
105
  spoofing_protection_backend.present?
130
106
  end
@@ -147,14 +123,14 @@ module ActiveStorageValidations
147
123
  ERROR_MESSAGE
148
124
  end
149
125
 
150
- def attachable_content_type_vs_detected_content_type_mismatch?
151
- @attachable_content_type.present? &&
152
- !attachable_content_type_intersects_detected_content_type?
126
+ def content_type_mismatch?(attachable_content_type, detected_content_type)
127
+ attachable_content_type.present? &&
128
+ !content_types_intersect?(attachable_content_type, detected_content_type)
153
129
  end
154
130
 
155
- def attachable_content_type_intersects_detected_content_type?
156
- enlarged_content_type(content_type_without_parameters(@attachable_content_type)).intersect?(
157
- enlarged_content_type(content_type_without_parameters(@detected_content_type))
131
+ def content_types_intersect?(attachable_content_type, detected_content_type)
132
+ enlarged_content_type(content_type_without_parameters(attachable_content_type)).intersect?(
133
+ enlarged_content_type(content_type_without_parameters(detected_content_type))
158
134
  )
159
135
  end
160
136
 
@@ -166,26 +142,26 @@ module ActiveStorageValidations
166
142
  Marcel::TYPE_PARENTS[content_type] || []
167
143
  end
168
144
 
169
- def add_content_type_invalid_error(record, attribute, attachable)
170
- errors_options = initialize_and_populate_error_options(options, attachable)
145
+ def add_content_type_invalid_error(record, attribute, attachable, context)
146
+ errors_options = initialize_and_populate_error_options(options, attachable, context)
171
147
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
172
148
  false
173
149
  end
174
150
 
175
- def add_content_type_spoofed_error(record, attribute, attachable, detected_content_type)
176
- errors_options = initialize_and_populate_error_options(options, attachable)
177
- errors_options[:detected_content_type] = @detected_content_type
178
- errors_options[:detected_human_content_type] = content_type_to_human_format(@detected_content_type)
151
+ def add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
152
+ errors_options = initialize_and_populate_error_options(options, attachable, context)
153
+ errors_options[:detected_content_type] = detected_content_type
154
+ errors_options[:detected_human_content_type] = content_type_to_human_format(detected_content_type)
179
155
  add_error(record, attribute, ERROR_TYPES.second, **errors_options)
180
156
  false
181
157
  end
182
158
 
183
- def initialize_and_populate_error_options(options, attachable)
159
+ def initialize_and_populate_error_options(options, attachable, context)
184
160
  errors_options = initialize_error_options(options, attachable)
185
- errors_options[:content_type] = @attachable_content_type
186
- errors_options[:human_content_type] = content_type_to_human_format(@attachable_content_type)
187
- errors_options[:authorized_human_content_types] = content_type_to_human_format(@authorized_content_types)
188
- errors_options[:count] = @authorized_content_types.size
161
+ errors_options[:content_type] = context.content_type
162
+ errors_options[:human_content_type] = content_type_to_human_format(context.content_type)
163
+ errors_options[:authorized_human_content_types] = content_type_to_human_format(context.authorized_content_types)
164
+ errors_options[:count] = context.authorized_content_types.size
189
165
  errors_options
190
166
  end
191
167
 
@@ -30,7 +30,9 @@ module ActiveStorageValidations
30
30
  next
31
31
  end
32
32
 
33
- if duration.to_i <= 0
33
+ # to_f, not to_i: durations are floats, and truncating would treat any
34
+ # file shorter than a second as unanalyzable.
35
+ if duration.to_f <= 0
34
36
  add_media_metadata_missing_error(record, attribute, attachable)
35
37
  next
36
38
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
+ # Registers the gem as a Rails engine so `config/locales/*.yml` is added to
5
+ # the I18n load path. Initializers live on {Railtie} (Active Record include,
6
+ # FormBuilder prepend, Blob metadata).
4
7
  class Engine < ::Rails::Engine
5
8
  end
6
9
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "marcel"
4
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)
5
+ Marcel::MimeType.extend "application/x-rar-compressed", parents: %w[application/x-rar]
6
+ Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %w[audio/x-aac]
7
+ Marcel::MimeType.extend "audio/x-m4a", parents: %w[audio/mp4]
8
+ Marcel::MimeType.extend "text/xml", parents: %w[application/xml] # alias
9
+ Marcel::MimeType.extend "video/theora", parents: %w[video/ogg]
10
10
 
11
11
  # Add empty content type
12
12
  Marcel::MimeType.extend "inode/x-empty", extensions: %w[empty]
@@ -33,7 +33,7 @@ module ActiveStorageValidations
33
33
  initialize_messageable
34
34
  initialize_rspecable
35
35
  @attribute_name = attribute_name
36
- @min = @max = nil
36
+ @min = @max = @exact = nil
37
37
  end
38
38
 
39
39
  def less_than(value)
@@ -107,12 +107,15 @@ module ActiveStorageValidations
107
107
  @min.nil? || !passes_validation_with_value(@min - 1)
108
108
  end
109
109
 
110
+ # Probe the inclusive bound itself. Checking only min+1 / max-1 lets
111
+ # less_than(n) satisfy less_than_or_equal_to(n) (and greater_than
112
+ # satisfy greater_than_or_equal_to).
110
113
  def higher_than_min?
111
- @min.nil? || passes_validation_with_value(@min + 1)
114
+ @min.nil? || passes_validation_with_value(@min)
112
115
  end
113
116
 
114
117
  def lower_than_max?
115
- @max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max - 1)
118
+ @max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max)
116
119
  end
117
120
 
118
121
  def not_higher_than_max?
@@ -120,7 +123,11 @@ module ActiveStorageValidations
120
123
  end
121
124
 
122
125
  def equal_to_exact?
123
- @exact.nil? || passes_validation_with_value(@exact)
126
+ return true if @exact.nil?
127
+
128
+ passes_validation_with_value(@exact) &&
129
+ !passes_validation_with_value(@exact - smallest_measurement) &&
130
+ !passes_validation_with_value(@exact + smallest_measurement)
124
131
  end
125
132
 
126
133
  def smallest_measurement
@@ -61,6 +61,7 @@ module ActiveStorageValidations
61
61
  is_a_valid_active_storage_attribute? &&
62
62
  is_context_valid? &&
63
63
  is_except_on_valid? &&
64
+ is_allowing_blank? &&
64
65
  is_custom_message_valid? &&
65
66
  file_count_not_smaller_than_min? &&
66
67
  file_count_equal_min? &&
@@ -83,7 +84,7 @@ module ActiveStorageValidations
83
84
  end
84
85
 
85
86
  def file_count_not_smaller_than_min?
86
- @min.nil? || @min.zero? || !passes_validation_with_limits(@min - 1)
87
+ @min.nil? || @min.zero? || (@allow_blank && @min == 1) || !passes_validation_with_limits(@min - 1)
87
88
  end
88
89
 
89
90
  def file_count_equal_min?
@@ -51,6 +51,7 @@ module ActiveStorageValidations
51
51
  is_a_valid_active_storage_attribute? &&
52
52
  is_context_valid? &&
53
53
  is_except_on_valid? &&
54
+ is_allowing_blank? &&
54
55
  is_timeout_valid? &&
55
56
  is_custom_message_valid? &&
56
57
  is_valid_when_image_processable? &&
@@ -21,7 +21,7 @@ module ActiveStorageValidations
21
21
  def is_allowing_blank?
22
22
  return true unless @allow_blank
23
23
 
24
- validate
24
+ attribute_validators.any? { |validator| validator.options[:allow_blank] }
25
25
  end
26
26
  end
27
27
  end
@@ -14,19 +14,30 @@ require "active_storage_validations/matchers/pages_validator_matcher"
14
14
 
15
15
  module ActiveStorageValidations
16
16
  module Matchers
17
- # Helper to stub a method with either RSpec or Minitest (whatever is available)
17
+ # Temporary singleton-method wrap. Independent of RSpec::Mocks and of
18
+ # Minitest::Mock / Object#stub (extracted to minitest-mock in Minitest 6),
19
+ # so size / metadata matchers work on stock Minitest 5 and 6.
18
20
  def self.stub_method(object, method, result)
19
- if defined?(Minitest::Mock)
20
- object.stub(method, result) do
21
- yield
22
- end
23
- elsif defined?(RSpec::Mocks)
24
- RSpec::Mocks.allow_message(object, method) { result }
25
- yield
26
- else
27
- raise "Need either Minitest::Mock or RSpec::Mocks to run this validator matcher"
21
+ singleton = object.singleton_class
22
+ owned = singleton.instance_methods(false).include?(method)
23
+ original = owned ? singleton.instance_method(method) : nil
24
+
25
+ singleton.define_method(method) { |*_args, **_kwargs| result }
26
+ yield
27
+ ensure
28
+ restore_stubbed_method(singleton, method, owned, original)
29
+ end
30
+
31
+ def self.restore_stubbed_method(singleton, method, owned, original)
32
+ return unless singleton
33
+
34
+ if owned
35
+ singleton.define_method(method, original)
36
+ elsif singleton.instance_methods(false).include?(method)
37
+ singleton.remove_method(method)
28
38
  end
29
39
  end
40
+ private_class_method :restore_stubbed_method
30
41
 
31
42
  def self.mock_metadata(attachment, metadata = {})
32
43
  asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
@@ -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.1.0"
4
+ VERSION = "4.1.1"
5
5
  end
@@ -27,11 +27,17 @@ module ActiveStorageValidations
27
27
 
28
28
  def is_valid?(record, attribute, attachable, metadata)
29
29
  expected_audio = options.fetch(:with, true)
30
- return if metadata&.fetch(:audio, false) == expected_audio
30
+ return if audio_track?(metadata) == expected_audio
31
31
 
32
32
  errors_options = initialize_error_options(options, attachable)
33
33
  error_type = expected_audio ? :audio_missing : :audio_present
34
34
  add_error(record, attribute, error_type, **errors_options)
35
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
36
42
  end
37
43
  end
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.1.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-18 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