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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21b87628a4e532d945c76ceca7d37444c7524f15ed11b587a4bab0a5eae7eaef
4
- data.tar.gz: 90fd47c4a439b00503e8997de2e344ace1771d15206a1e147fb785b94280abd4
3
+ metadata.gz: 5a6bd490e0f58921da42838a5df629556c0e9f84a01c114b8772ac195afea917
4
+ data.tar.gz: 397f02aeea00242f94af79a422d37dbcd90c1e07946107bf08f2dccf1da0c793
5
5
  SHA512:
6
- metadata.gz: b851e77dcdd882cb2bcae49dc3871c5d5d5920409cb94bba357a77ecd55ff34efd0266fe52bb0866bf31db52eb324bc0ef80749d2805dd27b97d7c89a2adcb51
7
- data.tar.gz: 422c62e2832c2adf34686832ec76ecc570f093133110838234c854c2ede5485324ebf9865aeb911f590a9c003485fd2d6535cced5e74d6ec686a562157ce983a
6
+ metadata.gz: ffc3c1b5b9a722dbc2b33d57560fdc41a2efb5ec68095883e15d251024e073f5d34342a2c0f10515794bd1ea6aeed1de593ca0e345af9cfb130d2d424543bf9a
7
+ data.tar.gz: aaf819d38fd5e91de9eeb68248b9d8e4f0faf733a9b5773a682819bb19933b6e740336315842914a1962b9ef2c39f2b9637fd3c481e584dd9c4f566237f0ace5
data/README.md CHANGED
@@ -31,6 +31,7 @@ This gem is doing it right for you! Just use `validates :avatar, attached: true,
31
31
  - [Total size](#total-size)
32
32
  - [Dimension](#dimension)
33
33
  - [Duration](#duration)
34
+ - [With audio](#with-audio)
34
35
  - [Aspect ratio](#aspect-ratio)
35
36
  - [Processable file](#processable-file)
36
37
  - [Pages](#pages)
@@ -76,7 +77,7 @@ We recommend **libvips** (`ruby-vips` + `config.active_storage.variant_processor
76
77
 
77
78
  ### Using video and audio metadata validators
78
79
 
79
- To use the video and audio metadata validators (`dimension`, `aspect_ratio`, `processable_file` and `duration`), you will not need to add any gems. However you will need to have the `ffmpeg` command-line tool installed on your system (once again, be sure to have it installed both on your local and in your CI / production environments).
80
+ To use the video and audio metadata validators (`dimension`, `aspect_ratio`, `processable_file`, `duration` and `with_audio`), you will not need to add any gems. However you will need to have the `ffmpeg` command-line tool installed on your system (once again, be sure to have it installed both on your local and in your CI / production environments).
80
81
 
81
82
  ### Using pdf metadata validators
82
83
 
@@ -113,9 +114,22 @@ end
113
114
  # event = ActiveSupport::Notifications::Event.new(*args)
114
115
  # Rails.logger.warn("[ASV] command timeout: #{event.payload}")
115
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
116
123
  ```
117
124
 
118
- `command_timeout` bounds metadata analysis used by `dimension`, `aspect_ratio`, `duration`, `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` / content-type errors) — there is no separate timeout error message.
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
+
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.
119
133
 
120
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).
121
135
 
@@ -140,6 +154,7 @@ Notes:
140
154
  - [Total size](#total-size): validates total file size for several files
141
155
  - [Dimension](#dimension): validates image / video dimensions
142
156
  - [Duration](#duration): validates video / audio duration
157
+ - [With audio](#with-audio): validates whether a video / audio file contains an audio track
143
158
  - [Aspect ratio](#aspect-ratio): validates image / video aspect ratio
144
159
  - [Processable file](#processable-file): validates if a file can be processed
145
160
  - [Pages](#pages): validates pdf number of pages
@@ -161,6 +176,8 @@ Some validators rely on an expensive operation (metadata analysis and content ty
161
176
 
162
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.
163
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
+
164
181
  ---
165
182
 
166
183
  ### Attached
@@ -317,7 +334,8 @@ ActiveStorageValidations.configure { |config| config.infer_file_field_accept = f
317
334
 
318
335
  Notes:
319
336
  - Only broad MIME-type regexes of the form `/\Aimage\/.*\z/` (or `video` / `audio` / etc.) are inferred, as `image/*`
320
- - 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
321
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
322
340
 
323
341
  #### Content type shorthands
@@ -362,7 +380,7 @@ Spoofing protection compares the declared Active Storage content type to a type
362
380
 
363
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.
364
382
 
365
- 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`.
366
384
 
367
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`).
368
386
 
@@ -644,6 +662,49 @@ The `duration` validator error messages expose 4 values that you can use:
644
662
 
645
663
  ---
646
664
 
665
+ ### With audio
666
+
667
+ Validates whether attached video / audio files contain an audio track.
668
+ (be sure to have the right dependencies installed as mentioned in [Using video and audio metadata validators](#using-video-and-audio-metadata-validators))
669
+
670
+ #### Options
671
+
672
+ The `with_audio` validator supports:
673
+ - `with`: `true` requires an audio track and `false` rejects one
674
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
675
+
676
+ #### Examples
677
+
678
+ Use it like this:
679
+ ```ruby
680
+ class User < ApplicationRecord
681
+ has_one_attached :video
682
+ has_one_attached :silent_video
683
+
684
+ validates :video, with_audio: true
685
+ validates :silent_video, with_audio: { with: false }
686
+ validates :video, with_audio: { timeout: 5.seconds }
687
+ end
688
+ ```
689
+
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 }`.
693
+
694
+ #### Error messages (I18n)
695
+
696
+ ```yml
697
+ en:
698
+ errors:
699
+ messages:
700
+ audio_missing: "must have an audio track"
701
+ audio_present: "must not have an audio track"
702
+ ```
703
+
704
+ The `with_audio` validator error message exposes the `filename` value containing the current file name.
705
+
706
+ ---
707
+
647
708
  ### Aspect ratio
648
709
 
649
710
  Validates the aspect ratio of the attached image / video files.
@@ -895,6 +956,10 @@ describe User do
895
956
  it { is_expected.to validate_duration_of(:introduction).between(100..500.seconds) }
896
957
  it { is_expected.to validate_duration_of(:avatar).equal_to(5.minutes) }
897
958
 
959
+ # with_audio
960
+ it { is_expected.to validate_with_audio_of(:video) }
961
+ it { is_expected.to validate_with_audio_of(:silent_video).without_audio }
962
+
898
963
  # pages:
899
964
  # #less_than, #less_than_or_equal_to, #greater_than, #greater_than_or_equal_to, #between, #equal_to
900
965
  it { is_expected.to validate_pages_of(:contract).less_than(50) }
@@ -911,9 +976,6 @@ All matchers can currently be customized with these options:
911
976
 
912
977
  ```ruby
913
978
  describe User do
914
- # :allow_blank
915
- it { is_expected.to validate_attached_of(:avatar).allow_blank }
916
-
917
979
  # :on
918
980
  it { is_expected.to validate_attached_of(:avatar).on(:update) }
919
981
  it { is_expected.to validate_attached_of(:avatar).on(%i[update custom]) }
@@ -927,6 +989,7 @@ describe User do
927
989
 
928
990
  # :timeout (analyzer command timeout — metadata validators + content_type with spoofing)
929
991
  it { is_expected.to validate_duration_of(:video).less_than(5.minutes).timeout(30.seconds) }
992
+ it { is_expected.to validate_with_audio_of(:video).timeout(5.seconds) }
930
993
  it { is_expected.to validate_processable_file_of(:avatar).timeout(5.seconds) }
931
994
  end
932
995
  ```
@@ -934,7 +997,7 @@ end
934
997
  ### Minitest
935
998
 
936
999
  #### Setup
937
- 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).
938
1001
 
939
1002
  You need to require the matchers:
940
1003
 
@@ -55,6 +55,8 @@ da:
55
55
  aspect_ratio_not_x_y: "skal være %{authorized_aspect_ratios} (nuværende fil er %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "har et ugyldigt aspektforhold (gyldige aspektforhold er %{authorized_aspect_ratios})"
57
57
  file_not_processable: "identificeres ikke som en gyldig mediefil"
58
+ audio_missing: "skal have et lydspor"
59
+ audio_present: "må ikke have et lydspor"
58
60
  pages_not_less_than: "sideantal skal være mindre end %{max} (aktuelt sideantal er %{pages})"
59
61
  pages_not_less_than_or_equal_to: "sideantal skal være mindre end eller lig med %{max} (aktuelt sideantal er %{pages})"
60
62
  pages_not_greater_than: "sideantal skal være større end %{min} (aktuelt sideantal er %{pages})"
@@ -55,6 +55,8 @@ de:
55
55
  aspect_ratio_not_x_y: "muss %{authorized_aspect_ratios} sein (aktuelle Datei ist %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "hat ein ungültiges Seitenverhältnis (gültige Seitenverhältnisse sind %{authorized_aspect_ratios})"
57
57
  file_not_processable: "wird nicht als gültige Mediendatei identifiziert"
58
+ audio_missing: "muss eine Audiospur enthalten"
59
+ audio_present: "darf keine Audiospur enthalten"
58
60
  pages_not_less_than: "Die Seitenanzahl muss kleiner als %{max} sein (die aktuelle Seitenanzahl beträgt %{pages})."
59
61
  pages_not_less_than_or_equal_to: "Die Seitenanzahl muss kleiner oder gleich %{max} sein (die aktuelle Seitenanzahl beträgt %{pages})."
60
62
  pages_not_greater_than: "Die Seitenanzahl muss größer als %{min} sein (die aktuelle Seitenanzahl beträgt %{pages})."
@@ -55,6 +55,8 @@ en-GB:
55
55
  aspect_ratio_not_x_y: "must be %{authorized_aspect_ratios} (current file is %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "has an invalid aspect ratio (valid aspect ratios are %{authorized_aspect_ratios})"
57
57
  file_not_processable: "is not identified as a valid media file"
58
+ audio_missing: "must have an audio track"
59
+ audio_present: "must not have an audio track"
58
60
  pages_not_less_than: "page count must be less than %{max} (current page count is %{pages})"
59
61
  pages_not_less_than_or_equal_to: "page count must be less than or equal to %{max} (current page count is %{pages})"
60
62
  pages_not_greater_than: "page count must be greater than %{min} (current page count is %{pages})"
@@ -55,6 +55,8 @@ en:
55
55
  aspect_ratio_not_x_y: "must be %{authorized_aspect_ratios} (current file is %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "has an invalid aspect ratio (valid aspect ratios are %{authorized_aspect_ratios})"
57
57
  file_not_processable: "is not identified as a valid media file"
58
+ audio_missing: "must have an audio track"
59
+ audio_present: "must not have an audio track"
58
60
  pages_not_less_than: "page count must be less than %{max} (current page count is %{pages})"
59
61
  pages_not_less_than_or_equal_to: "page count must be less than or equal to %{max} (current page count is %{pages})"
60
62
  pages_not_greater_than: "page count must be greater than %{min} (current page count is %{pages})"
@@ -55,6 +55,8 @@ es:
55
55
  aspect_ratio_not_x_y: "debe ser %{authorized_aspect_ratios} (el archivo actual es %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "tiene una relación de aspecto inválida (las relaciones de aspecto válidas son %{authorized_aspect_ratios})"
57
57
  file_not_processable: "no se identifica como un archivo multimedia válido"
58
+ audio_missing: "debe tener una pista de audio"
59
+ audio_present: "no debe tener una pista de audio"
58
60
  pages_not_less_than: "el número de páginas debe ser menor que %{max} (el número actual de páginas es %{pages})"
59
61
  pages_not_less_than_or_equal_to: "el número de páginas debe ser menor o igual que %{max} (el número actual de páginas es %{pages})"
60
62
  pages_not_greater_than: "el número de páginas debe ser mayor que %{min} (el número actual de páginas es %{pages})"
@@ -55,6 +55,8 @@ fr:
55
55
  aspect_ratio_not_x_y: "doit être %{authorized_aspect_ratios} (le fichier actuel est %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "a un rapport d'aspect invalide (les rapports d'aspect valides sont %{authorized_aspect_ratios})"
57
57
  file_not_processable: "n'est pas identifié comme un fichier média valide"
58
+ audio_missing: "doit contenir une piste audio"
59
+ audio_present: "ne doit pas contenir de piste audio"
58
60
  pages_not_less_than: "le nombre de pages doit être inférieur à %{max} (le nombre actuel est de %{pages})"
59
61
  pages_not_less_than_or_equal_to: "le nombre de pages doit être inférieur ou égal à %{max} (le nombre actuel est de %{pages})"
60
62
  pages_not_greater_than: "le nombre de pages doit être supérieur à %{min} (le nombre actuel est de %{pages})"
@@ -55,6 +55,8 @@ it:
55
55
  aspect_ratio_not_x_y: "deve essere %{authorized_aspect_ratios} (il file corrente è %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "ha un formato non valido (i rapporti di aspetto validi sono %{authorized_aspect_ratios})"
57
57
  file_not_processable: "non è identificato come file multimediale valido"
58
+ audio_missing: "deve contenere una traccia audio"
59
+ audio_present: "non deve contenere una traccia audio"
58
60
  pages_not_less_than: "il numero di pagine deve essere inferiore a %{max} (il numero di pagine corrente è %{pages})"
59
61
  pages_not_less_than_or_equal_to: "il numero di pagine deve essere inferiore o uguale a %{max} (il numero di pagine corrente è %{pages})"
60
62
  pages_not_greater_than: "il numero di pagine deve essere maggiore di %{min} (il numero di pagine corrente è %{pages})"
@@ -55,6 +55,8 @@ ja:
55
55
  aspect_ratio_not_x_y: "のアスペクト比は%{authorized_aspect_ratios}にしてください (添付されたメディアの解像度は%{width}x%{height})"
56
56
  aspect_ratio_invalid: "のアスペクト比を検出できません (許可されたアスペクト比は%{authorized_aspect_ratios})"
57
57
  file_not_processable: "のメディアを処理できません"
58
+ audio_missing: "には音声トラックが必要です"
59
+ audio_present: "に音声トラックを含めることはできません"
58
60
  pages_not_less_than: "のページ数は%{max}未満にしてください (添付されたファイルは%{pages}ページ)"
59
61
  pages_not_less_than_or_equal_to: "のページ数は%{max}以下にしてください (添付されたファイルは%{pages}ページ)"
60
62
  pages_not_greater_than: "のページ数は%{min}より大きくしてください (添付されたファイルは%{pages}ページ)"
@@ -55,6 +55,8 @@ nl:
55
55
  aspect_ratio_not_x_y: "moet %{authorized_aspect_ratios} zijn (huidig bestand is %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "heeft een ongeldige beeldverhouding (geldige beeldverhoudingen zijn %{authorized_aspect_ratios})"
57
57
  file_not_processable: "wordt niet geïdentificeerd als een geldig mediabestand"
58
+ audio_missing: "moet een audiotrack bevatten"
59
+ audio_present: "mag geen audiotrack bevatten"
58
60
  pages_not_less_than: "het aantal pagina's moet kleiner zijn dan %{max} (het huidige aantal pagina's is %{pages})"
59
61
  pages_not_less_than_or_equal_to: "het aantal pagina's moet kleiner zijn dan of gelijk zijn aan %{max} (het huidige aantal pagina's is %{pages})"
60
62
  pages_not_greater_than: "het aantal pagina's moet groter zijn dan %{min} (het huidige aantal pagina's is %{pages})"
@@ -55,6 +55,8 @@ pl:
55
55
  aspect_ratio_not_x_y: "musi być %{authorized_aspect_ratios} (bieżący plik to %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "ma nieprawidłowy współczynnik kształtu (ważne współczynniki kształtu wynoszą %{authorized_aspect_ratios})"
57
57
  file_not_processable: "nie jest identyfikowany jako prawidłowy plik multimedialny"
58
+ audio_missing: "musi zawierać ścieżkę dźwiękową"
59
+ audio_present: "nie może zawierać ścieżki dźwiękowej"
58
60
  pages_not_less_than: "liczba stron musi być mniejsza niż %{max} (bieżąca liczba stron to %{pages})"
59
61
  pages_not_less_than_or_equal_to: "liczba stron musi być mniejsza lub równa %{max} (bieżąca liczba stron to %{pages})"
60
62
  pages_not_greater_than: "liczba stron musi być większa niż %{min} (bieżąca liczba stron to %{pages})"
@@ -55,6 +55,8 @@ pt-BR:
55
55
  aspect_ratio_not_x_y: "deve ser %{authorized_aspect_ratios} (o arquivo atual é %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "tem uma proporção inválida (as proporções válidas são %{authorized_aspect_ratios})"
57
57
  file_not_processable: "não é identificado como um arquivo de mídia válido"
58
+ audio_missing: "deve ter uma faixa de áudio"
59
+ audio_present: "não deve ter uma faixa de áudio"
58
60
  pages_not_less_than: "a contagem de páginas deve ser menor que %{max} (a contagem atual de páginas é %{pages})"
59
61
  pages_not_less_than_or_equal_to: "a contagem de páginas deve ser menor ou igual a %{max} (a contagem atual de páginas é %{pages})"
60
62
  pages_not_greater_than: "a contagem de páginas deve ser maior que %{min} (a contagem atual de páginas é %{pages})"
@@ -55,6 +55,8 @@ ru:
55
55
  aspect_ratio_not_x_y: "должно быть %{authorized_aspect_ratios} (текущий файл %{width}x%{height} пикселей)"
56
56
  aspect_ratio_invalid: "имеет недействительное соотношение сторон (действительные соотношения сторон %{authorized_aspect_ratios})"
57
57
  file_not_processable: "не идентифицируется как действительный медиафайл"
58
+ audio_missing: "должен содержать аудиодорожку"
59
+ audio_present: "не должен содержать аудиодорожку"
58
60
  pages_not_less_than: "количество страниц должно быть меньше %{max} (текущее количество страниц %{pages})"
59
61
  pages_not_less_than_or_equal_to: "количество страниц должно быть меньше или равно %{max} (текущее количество страниц %{pages})"
60
62
  pages_not_greater_than: "количество страниц должно быть больше %{min} (текущее количество страниц %{pages})"
@@ -52,9 +52,11 @@ 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
+ audio_missing: "måste innehålla ett ljudspår"
59
+ audio_present: "får inte innehålla ett ljudspår"
58
60
  pages_not_less_than: "sidantal måste vara mindre än %{max} (nuvarande sidantal är %{pages})"
59
61
  pages_not_less_than_or_equal_to: "sidantal måste vara mindre än eller lika med %{max} (nuvarande antal sidor är %{pages})"
60
62
  pages_not_greater_than: "sidantal måste vara större än %{min} (nuvarande sidantal är %{pages})"
@@ -55,6 +55,8 @@ tr:
55
55
  aspect_ratio_not_x_y: "%{authorized_aspect_ratios} olmalıdır (geçerli dosya %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "geçersiz bir en boy oranına sahiptir (geçerli en boy oranları %{authorized_aspect_ratios})"
57
57
  file_not_processable: "geçerli bir medya dosyası olarak tanımlanmadı"
58
+ audio_missing: "bir ses parçası içermelidir"
59
+ audio_present: "bir ses parçası içermemelidir"
58
60
  pages_not_less_than: "sayfa sayısı %{max} değerinden az olmalıdır (geçerli sayfa sayısı %{pages})"
59
61
  pages_not_less_than_or_equal_to: "sayfa sayısı %{max} değerinden az veya eşit olmalıdır (geçerli sayfa sayısı %{pages})"
60
62
  pages_not_greater_than: "sayfa sayısı %{min} değerinden fazla olmalıdır (geçerli sayfa sayısı %{pages})"
@@ -55,6 +55,8 @@ uk:
55
55
  aspect_ratio_not_x_y: "повинен бути %{authorized_aspect_ratios} (поточний файл становить %{width}x%{height} пікселям)"
56
56
  aspect_ratio_invalid: "має недійсне співвідношення сторін (дійсні співвідношення сторін %{authorized_aspect_ratios})"
57
57
  file_not_processable: "не ідентифікується як дійсний медіа-файл"
58
+ audio_missing: "має містити аудіодоріжку"
59
+ audio_present: "не має містити аудіодоріжку"
58
60
  pages_not_less_than: "кількість сторінок має бути менше %{max} (поточна кількість сторінок %{pages})"
59
61
  pages_not_less_than_or_equal_to: "кількість сторінок має бути меншою або дорівнювати %{max} (поточна кількість сторінок становить %{pages})"
60
62
  pages_not_greater_than: "кількість сторінок має бути більшою за %{min} (поточна кількість сторінок становить %{pages})"
@@ -55,6 +55,8 @@ vi:
55
55
  aspect_ratio_not_x_y: "phải là %{authorized_aspect_ratios} (tệp hiện tại là %{width}x%{height}px)"
56
56
  aspect_ratio_invalid: "có tỷ lệ khung hình không hợp lệ (tỷ lệ khung hình hợp lệ là %{authorized_aspect_ratios})"
57
57
  file_not_processable: "không được xác định là tệp phương tiện hợp lệ"
58
+ audio_missing: "phải có một bản âm thanh"
59
+ audio_present: "không được chứa bản âm thanh"
58
60
  pages_not_less_than: "số trang phải nhỏ hơn %{max} (số trang hiện tại là %{pages})"
59
61
  pages_not_less_than_or_equal_to: "số trang phải nhỏ hơn hoặc bằng %{max} (số trang hiện tại là %{pages})"
60
62
  pages_not_greater_than: "số trang phải lớn hơn %{min} (số trang hiện tại là %{pages})"
@@ -55,6 +55,8 @@ zh-CN:
55
55
  aspect_ratio_not_x_y: "必须为 %{authorized_aspect_ratios}(当前文件为 %{width} x%{height} 像素)"
56
56
  aspect_ratio_invalid: "具有无效的长宽比(有效长宽比为 %{authorized_aspect_ratios})"
57
57
  file_not_processable: "未标识为有效的媒体文件"
58
+ audio_missing: "必须包含音轨"
59
+ audio_present: "不得包含音轨"
58
60
  pages_not_less_than: "页数必须小于 %{max}(当前页数为 %{pages})"
59
61
  pages_not_less_than_or_equal_to: "页数必须小于或等于 %{max}(当前页数为 %{pages})"
60
62
  pages_not_greater_than: "页数必须大于 %{min}(当前页数为 %{pages})"
@@ -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