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.
- checksums.yaml +4 -4
- data/README.md +24 -9
- data/config/locales/sv.yml +2 -2
- data/lib/active_storage_validations/analyzer/audio_analyzer.rb +7 -1
- data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +0 -2
- data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +0 -2
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +3 -0
- data/lib/active_storage_validations/analyzer/image_analyzer.rb +16 -6
- data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +11 -2
- data/lib/active_storage_validations/analyzer.rb +2 -35
- data/lib/active_storage_validations/aspect_ratio_validator.rb +21 -15
- data/lib/active_storage_validations/asv_attachable_adapter.rb +221 -0
- data/lib/active_storage_validations/content_type_validator.rb +40 -64
- data/lib/active_storage_validations/duration_validator.rb +3 -1
- data/lib/active_storage_validations/engine.rb +3 -0
- data/lib/active_storage_validations/extensors/asv_marcelable.rb +5 -5
- data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -4
- data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +2 -1
- data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +1 -0
- data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +1 -1
- data/lib/active_storage_validations/matchers.rb +21 -10
- data/lib/active_storage_validations/shared/asv_analyzable.rb +51 -8
- data/lib/active_storage_validations/shared/asv_attachable.rb +14 -97
- data/lib/active_storage_validations/shared/asv_errorable.rb +9 -8
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations/with_audio_validator.rb +7 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a6bd490e0f58921da42838a5df629556c0e9f84a01c114b8772ac195afea917
|
|
4
|
+
data.tar.gz: 397f02aeea00242f94af79a422d37dbcd90c1e07946107bf08f2dccf1da0c793
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/`)
|
|
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
|
|
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
|
|
data/config/locales/sv.yml
CHANGED
|
@@ -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%
|
|
56
|
-
aspect_ratio_invalid: "har ett ogiltigt bildförhållande (giltiga bildförhållanden är %{
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
77
|
+
page_size_in_points[0]
|
|
73
78
|
end
|
|
74
79
|
|
|
75
80
|
def height
|
|
76
|
-
|
|
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 ||=
|
|
46
|
-
|
|
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*)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
def aspect_ratio_error_mapping(aspect_ratios)
|
|
82
|
+
return :aspect_ratio_invalid if aspect_ratios.many?
|
|
77
83
|
|
|
78
|
-
aspect_ratio =
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
return if
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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(
|
|
94
|
-
when Regexp then authorized_content_type.match?(marcel_attachable_content_type(
|
|
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(
|
|
104
|
-
Marcel::MimeType.for(declared_type:
|
|
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
|
-
|
|
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
|
|
118
|
-
add_content_type_spoofed_error(record, attribute, attachable,
|
|
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
|
|
151
|
-
|
|
152
|
-
!
|
|
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
|
|
156
|
-
enlarged_content_type(content_type_without_parameters(
|
|
157
|
-
enlarged_content_type(content_type_without_parameters(
|
|
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] =
|
|
178
|
-
errors_options[:detected_human_content_type] = content_type_to_human_format(
|
|
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] =
|
|
186
|
-
errors_options[:human_content_type] = content_type_to_human_format(
|
|
187
|
-
errors_options[:authorized_human_content_types] = content_type_to_human_format(
|
|
188
|
-
errors_options[:count] =
|
|
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
|
-
|
|
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: %
|
|
6
|
-
Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %
|
|
7
|
-
Marcel::MimeType.extend "audio/x-m4a", parents: %
|
|
8
|
-
Marcel::MimeType.extend "text/xml", parents: %
|
|
9
|
-
Marcel::MimeType.extend "video/theora", parents: %
|
|
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
|
|
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
|
|
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?
|
|
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?
|
|
@@ -14,19 +14,30 @@ require "active_storage_validations/matchers/pages_validator_matcher"
|
|
|
14
14
|
|
|
15
15
|
module ActiveStorageValidations
|
|
16
16
|
module Matchers
|
|
17
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
|
31
|
+
blob.merge_into_active_storage_validations_metadata(memoize_unavailable_keys(new_metadata, metadata_keys))
|
|
32
|
+
persist_asv_metadata(blob)
|
|
29
33
|
|
|
30
|
-
blob
|
|
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
|
|
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
|
-
|
|
119
|
+
cached = media_metadata(blob)
|
|
120
|
+
return false unless cached.present?
|
|
78
121
|
|
|
79
|
-
metadata_keys.all? { |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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
@@ -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
|
|
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.
|
|
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-
|
|
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
|