active_storage_validations 1.2.0 → 1.3.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -17
  3. data/config/locales/da.yml +1 -1
  4. data/config/locales/de.yml +1 -0
  5. data/config/locales/en.yml +1 -0
  6. data/config/locales/es.yml +1 -0
  7. data/config/locales/fr.yml +1 -0
  8. data/config/locales/it.yml +1 -0
  9. data/config/locales/ja.yml +1 -0
  10. data/config/locales/nl.yml +1 -0
  11. data/config/locales/pl.yml +1 -0
  12. data/config/locales/pt-BR.yml +1 -0
  13. data/config/locales/ru.yml +1 -0
  14. data/config/locales/sv.yml +1 -0
  15. data/config/locales/tr.yml +1 -0
  16. data/config/locales/uk.yml +1 -0
  17. data/config/locales/vi.yml +1 -0
  18. data/config/locales/zh-CN.yml +1 -0
  19. data/lib/active_storage_validations/aspect_ratio_validator.rb +10 -34
  20. data/lib/active_storage_validations/attached_validator.rb +5 -3
  21. data/lib/active_storage_validations/base_size_validator.rb +3 -1
  22. data/lib/active_storage_validations/concerns/active_storageable.rb +28 -0
  23. data/lib/active_storage_validations/concerns/errorable.rb +2 -3
  24. data/lib/active_storage_validations/concerns/loggable.rb +9 -0
  25. data/lib/active_storage_validations/concerns/metadatable.rb +31 -0
  26. data/lib/active_storage_validations/content_type_spoof_detector.rb +130 -0
  27. data/lib/active_storage_validations/content_type_validator.rb +56 -22
  28. data/lib/active_storage_validations/dimension_validator.rb +31 -52
  29. data/lib/active_storage_validations/limit_validator.rb +5 -3
  30. data/lib/active_storage_validations/marcel_extensor.rb +5 -0
  31. data/lib/active_storage_validations/matchers/concerns/attachable.rb +27 -9
  32. data/lib/active_storage_validations/matchers/concerns/messageable.rb +1 -1
  33. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +7 -0
  34. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  35. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +1 -10
  36. data/lib/active_storage_validations/matchers.rb +4 -15
  37. data/lib/active_storage_validations/metadata.rb +7 -10
  38. data/lib/active_storage_validations/processable_image_validator.rb +17 -32
  39. data/lib/active_storage_validations/size_validator.rb +2 -6
  40. data/lib/active_storage_validations/total_size_validator.rb +3 -7
  41. data/lib/active_storage_validations/version.rb +1 -1
  42. data/lib/active_storage_validations.rb +2 -0
  43. metadata +18 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e1690b53c320459f3dcd874f52680d67b8e1f1b6dc68eccff486c5a664695a
4
- data.tar.gz: 5d1a163646400c3ee50feff6c2c847408fa8c54f42457d9493ddfa0c4f900ab0
3
+ metadata.gz: '09b483aef23513aaf10b56c6df6046078bf14e93fcfdebe2e82f2db862b85514'
4
+ data.tar.gz: d040c36c24fc1fb2b4d1a2e6bb92bde86521cedfda4b4e96436a10b364499f63
5
5
  SHA512:
6
- metadata.gz: 520513aab9962df2b30156c44685d3db9e86bb5bf73702dd11c54fd1834548bd5931d7d0841b26d9bc4bea3f4f765d72593565ca5c0c2c84efe414760f3e16b3
7
- data.tar.gz: 4fecb3d1b7b33a2c78a12747fe1b6a0509e419dd1a54e1e5c089044f627d68c7eead6aaa486d2baa7bb7ebff2f4654a60262718abf9524c69ea8281713cc626b
6
+ metadata.gz: eb6df31c0374b1bfa804281f0effbaa0443ff6a812abd4a46e1ac4293a881e691fd5c61cd4c2622b027085acb3d10c14255f04cac3bab01f13f133d2f26c60af
7
+ data.tar.gz: b5553a4a7a2f08542c8e6f9dd928b0cc78a09eb846f73346dcdec1b3826bb4a5f04ac200c21e6e2f0f58f6dcc173f226a04e7a2c85d7c15a24b5b827bfb04dcb
data/README.md CHANGED
@@ -90,6 +90,27 @@ Example code for adding a new content type to Marcel:
90
90
  Marcel::MimeType.extend "application/ino", extensions: %w(ino), parents: "text/plain" # Registering arduino INO files
91
91
  ```
92
92
 
93
+ **Content type spoofing protection**
94
+ File content type spoofing happens when an ill-intentioned user uploads a file which hides its true content type by faking its extension and its declared content type value. For example, a user may try to upload a `.exe` file (application/x-msdownload content type) dissimulated as a `.jpg` file (image/jpeg content type).
95
+
96
+ By default, the gem does not prevent content type spoofing (prevent it by default is a breaking change that will be implemented in v2). The spoofing protection relies on both the linux `file` command and `Marcel` gem.
97
+
98
+ Take note that the `file` analyzer will not find the exactly same content type as the ActiveStorage blob (its content type detection relies on a different logic using content+filename+extension). To handle this issue, we consider a close parent content type to be a match. For example, for an ActiveStorage blob which content type is `video/x-ms-wmv`, the `file` analyzer will probably detect a `video/x-ms-asf` content type, this will be considered as a valid match because these 2 content types are closely related. The correlation mapping is based on `Marcel::TYPE_PARENTS`.
99
+
100
+ The difficulty to accurately predict a mime type may generate false positives, if so there are two solutions available:
101
+ - If the ActiveStorage blob content type is closely related to the detected content type using the `file` analyzer, you can enhance `Marcel::TYPE_PARENTS` mapping using `Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)` in the `config/initializers/mime_types.rb` file. (Please drop an issue so we can add it to the gem for everyone!)
102
+ - If the ActiveStorage blob content type is not closely related, you still can disable the content type spoofing protection in the validator, if so, please drop us an issue so we can fix it for everyone!
103
+
104
+ ```ruby
105
+ class User < ApplicationRecord
106
+ has_one_attached :avatar
107
+
108
+ validates :avatar, attached: true, content_type: :png # spoofing_protection not enabled, at your own risks!
109
+ validates :avatar, attached: true, content_type: { with: :png, spoofing_protection: true } # spoofing_protection enabled
110
+ end
111
+ ```
112
+
113
+
93
114
  - Dimension validation with `width`, `height` and `in`.
94
115
 
95
116
  ```ruby
@@ -297,8 +318,7 @@ Very simple example of validation with file attached, content type check and cus
297
318
  [![Sample](https://raw.githubusercontent.com/igorkasyanchuk/active_storage_validations/master/docs/preview.png)](https://raw.githubusercontent.com/igorkasyanchuk/active_storage_validations/master/docs/preview.png)
298
319
 
299
320
  ## Test matchers
300
-
301
- Provides RSpec-compatible and Minitest-compatible matchers for testing the validators. Only `aspect_ratio`, `attached`, `content_type`, `processable_image`, `dimension`, `size` and `total_size` validators currently have their matcher developed.
321
+ Provides RSpec-compatible and Minitest-compatible matchers for testing the validators.
302
322
 
303
323
  ### RSpec
304
324
 
@@ -331,6 +351,11 @@ describe User do
331
351
  # processable_image
332
352
  it { is_expected.to validate_processable_image_of(:avatar) }
333
353
 
354
+ # limit
355
+ # #min, #max
356
+ it { is_expected.to validate_limit_of(:avatar).min(1) }
357
+ it { is_expected.to validate_limit_of(:avatar).max(5) }
358
+
334
359
  # content_type:
335
360
  # #allowing, #rejecting
336
361
  it { is_expected.to validate_content_type_of(:avatar).allowing('image/png', 'image/gif') }
@@ -411,7 +436,7 @@ Then you can use the matchers with the syntax specified in the RSpec section, ju
411
436
 
412
437
  To run tests in root folder of gem:
413
438
 
414
- * `BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle exec rake test` to run for Rails 6.1
439
+ * `BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle exec rake test` to run for Rails 6.1.4
415
440
  * `BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test` to run for Rails 7.0
416
441
  * `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test` to run for Rails 7.0
417
442
  * `BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test` to run for Rails main branch
@@ -419,11 +444,11 @@ To run tests in root folder of gem:
419
444
  Snippet to run in console:
420
445
 
421
446
  ```bash
422
- BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle
447
+ BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle
423
448
  BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle
424
449
  BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle
425
450
  BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle
426
- BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle exec rake test
451
+ BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle exec rake test
427
452
  BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test
428
453
  BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
429
454
  BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test
@@ -434,18 +459,6 @@ Tips:
434
459
  - To focus a specific file, use the TEST option provided by minitest, e.g. to only run size_validator_test.rb file you will execute the following command: `bundle exec rake test TEST=test/validators/size_validator_test.rb`
435
460
 
436
461
 
437
- ## Known issues
438
-
439
- - There is an issue in Rails which it possible to get if you have added a validation and generating for example an image preview of attachments. It can be fixed with this:
440
-
441
- ```erb
442
- <% if @user.avatar.attached? && @user.avatar.attachment.blob.present? && @user.avatar.attachment.blob.persisted? %>
443
- <%= image_tag @user.avatar %>
444
- <% end %>
445
- ```
446
-
447
- This is a Rails issue, and is fixed in Rails 6.
448
-
449
462
  ## Contributing
450
463
 
451
464
  You are welcome to contribute.
@@ -1,8 +1,8 @@
1
- # Danish
2
1
  da:
3
2
  errors:
4
3
  messages:
5
4
  content_type_invalid: "har en ugyldig indholdstype"
5
+ spoofed_content_type: "har en indholdstype, der ikke er, hvad den erklæres gennem sit indhold, filnavn og udvidelse"
6
6
  file_size_not_less_than: "filstørrelsen skal være mindre end %{max_size} (den nuværende størrelse er %{file_size})"
7
7
  file_size_not_less_than_or_equal_to: "filstørrelsen skal være mindre end eller lig med %{max_size} (den nuværende størrelse er %{file_size})"
8
8
  file_size_not_greater_than: "filstørrelsen skal være større end %{min_size} (den nuværende størrelse er %{file_size})"
@@ -2,6 +2,7 @@ de:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "hat einen ungültigen Dateityp"
5
+ spoofed_content_type: "hat einen Inhaltstyp, der nicht dem entspricht, was durch Inhalt, Dateinamen und Erweiterung deklariert wird"
5
6
  file_size_not_less_than: "Dateigröße muss kleiner als %{max_size} sein (aktuelle Dateigröße ist %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "Dateigröße muss kleiner oder gleich %{max_size} sein (aktuelle Dateigröße ist %{file_size})"
7
8
  file_size_not_greater_than: "Dateigröße muss größer als %{min_size} sein (aktuelle Dateigröße ist %{file_size})"
@@ -2,6 +2,7 @@ en:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "has an invalid content type"
5
+ spoofed_content_type: "has a content type that is not what it is declared through its content, filename and extension"
5
6
  file_size_not_less_than: "file size must be less than %{max_size} (current size is %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "file size must be less than or equal to %{max_size} (current size is %{file_size})"
7
8
  file_size_not_greater_than: "file size must be greater than %{min_size} (current size is %{file_size})"
@@ -2,6 +2,7 @@ es:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "tiene un tipo de contenido inválido"
5
+ spoofed_content_type: "tiene un tipo de contenido que no es el que se declara a través de su contenido, nombre de archivo y extensión"
5
6
  file_size_not_less_than: "el tamaño del archivo debe ser inferior a %{max_size} (el tamaño actual es %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "el tamaño del archivo debe ser menor o igual a %{max_size} (el tamaño actual es %{file_size})"
7
8
  file_size_not_greater_than: "el tamaño del archivo debe ser mayor que %{min_size} (el tamaño actual es %{file_size})"
@@ -2,6 +2,7 @@ fr:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "a un type de contenu non valide"
5
+ spoofed_content_type: "a un type de contenu qui n'est pas celui déclaré via son contenu, son nom de fichier et son extension"
5
6
  file_size_not_less_than: "la taille du fichier doit être inférieure à %{max_size} (la taille actuelle est %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "la taille du fichier doit être inférieure ou égale à %{max_size} (la taille actuelle est %{file_size})"
7
8
  file_size_not_greater_than: "la taille du fichier doit être supérieure à %{min_size} (la taille actuelle est %{file_size})"
@@ -2,6 +2,7 @@ it:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "ha un tipo di contenuto non valido"
5
+ spoofed_content_type: "ha un tipo di contenuto che non è quello dichiarato tramite contenuto, nome file ed estensione"
5
6
  file_size_not_less_than: "la dimensione del file deve essere inferiore a %{max_size} (la dimensione attuale è %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "la dimensione del file deve essere minore o uguale a %{max_size} (la dimensione attuale è %{file_size})"
7
8
  file_size_not_greater_than: "la dimensione del file deve essere maggiore di %{min_size} (la dimensione attuale è %{file_size})"
@@ -2,6 +2,7 @@ ja:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "のContent Typeが不正です"
5
+ spoofed_content_type: "コンテンツ、ファイル名、拡張子で宣言されているコンテンツ タイプと異なるコンテンツ タイプが含まれています"
5
6
  file_size_not_less_than: "ファイル サイズは %{max_size} 未満にする必要があります (現在のサイズは %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "ファイル サイズは %{max_size} 以下である必要があります (現在のサイズは %{file_size})"
7
8
  file_size_not_greater_than: "ファイル サイズは %{min_size} より大きい必要があります (現在のサイズは %{file_size} です)"
@@ -2,6 +2,7 @@ nl:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "heeft een ongeldig inhoudstype"
5
+ spoofed_content_type: "heeft een inhoudstype dat niet is wat het wordt aangegeven via de inhoud, bestandsnaam en extensie"
5
6
  file_size_not_less_than: "bestandsgrootte moet kleiner zijn dan %{max_size} (huidige grootte is %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "bestandsgrootte moet kleiner zijn dan of gelijk zijn aan %{max_size} (huidige grootte is %{file_size})"
7
8
  file_size_not_greater_than: "bestandsgrootte moet groter zijn dan %{min_size} (huidige grootte is %{file_size})"
@@ -2,6 +2,7 @@ pl:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "jest nieprawidłowego typu"
5
+ spoofed_content_type: "ma typ zawartości inny niż zadeklarowany w treści, nazwie pliku i rozszerzeniu"
5
6
  file_size_not_less_than: "rozmiar pliku musi być mniejszy niż %{max_size} (obecny rozmiar to %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "rozmiar pliku musi być mniejszy lub równy %{max_size} (obecny rozmiar to %{file_size})"
7
8
  file_size_not_greater_than: "rozmiar pliku musi być większy niż %{min_size} (obecny rozmiar to %{file_size})"
@@ -2,6 +2,7 @@ pt-BR:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "tem um tipo de arquivo inválido"
5
+ spoofed_content_type: "tem um tipo de conteúdo que não é o declarado através de seu conteúdo, nome de arquivo e extensão"
5
6
  file_size_not_less_than: "o tamanho do arquivo deve ser menor que %{max_size} (o tamanho atual é %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "o tamanho do arquivo deve ser menor ou igual a %{max_size} (o tamanho atual é %{file_size})"
7
8
  file_size_not_greater_than: "o tamanho do arquivo deve ser maior que %{min_size} (o tamanho atual é %{file_size})"
@@ -2,6 +2,7 @@ ru:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "имеет недопустимый тип содержимого"
5
+ spoofed_content_type: "имеет тип контента, который не соответствует тому, который объявлен в его содержимом, имени файла и расширении"
5
6
  file_size_not_less_than: "размер файла должен быть меньше %{max_size} (текущий размер %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "размер файла должен быть меньше или равен %{max_size} (текущий размер %{file_size})"
7
8
  file_size_not_greater_than: "размер файла должен быть больше %{min_size} (текущий размер %{file_size})"
@@ -2,6 +2,7 @@ sv:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "Har en ogiltig filtyp"
5
+ spoofed_content_type: "har en innehållstyp som inte är vad den deklareras genom sitt innehåll, filnamn och tillägg"
5
6
  file_size_not_less_than: "filstorleken måste vara mindre än %{max_size} (nuvarande storlek är %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "filstorleken måste vara mindre än eller lika med %{max_size} (nuvarande storlek är %{file_size})"
7
8
  file_size_not_greater_than: "filstorleken måste vara större än %{min_size} (nuvarande storlek är %{file_size})"
@@ -2,6 +2,7 @@ tr:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "geçersiz dosya tipine sahip"
5
+ spoofed_content_type: "içeriği, dosya adı ve uzantısı aracılığıyla bildirildiği gibi olmayan bir içerik türüne sahip"
5
6
  file_size_not_less_than: "dosya boyutu %{max_size} boyutundan küçük olmalıdır (geçerli boyut %{file_size}'dir)"
6
7
  file_size_not_less_than_or_equal_to: "dosya boyutu %{max_size} değerinden küçük veya ona eşit olmalıdır (geçerli boyut %{file_size}'dir)"
7
8
  file_size_not_greater_than: "dosya boyutu %{min_size} boyutundan büyük olmalıdır (geçerli boyut %{file_size}'dir)"
@@ -2,6 +2,7 @@ uk:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "має неприпустимий тип вмісту"
5
+ spoofed_content_type: "має тип вмісту, який не відповідає тому, що він оголошений через його вміст, назву файлу та розширення"
5
6
  file_size_not_less_than: "розмір файлу має бути менше %{max_size} (поточний розмір %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "розмір файлу має бути меншим або дорівнювати %{max_size} (поточний розмір %{file_size})"
7
8
  file_size_not_greater_than: "розмір файлу має бути більшим ніж %{min_size} (поточний розмір %{file_size})"
@@ -2,6 +2,7 @@ vi:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "tệp không hợp lệ"
5
+ spoofed_content_type: "có loại nội dung không phải là loại nội dung được khai báo thông qua nội dung, tên tệp và phần mở rộng của nó"
5
6
  file_size_not_less_than: "kích thước tệp phải nhỏ hơn %{max_size} (kích thước hiện tại là %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "kích thước tệp phải nhỏ hơn hoặc bằng %{max_size} (kích thước hiện tại là %{file_size})"
7
8
  file_size_not_greater_than: "kích thước tệp phải lớn hơn %{min_size} (kích thước hiện tại là %{file_size})"
@@ -2,6 +2,7 @@ zh-CN:
2
2
  errors:
3
3
  messages:
4
4
  content_type_invalid: "文件类型错误"
5
+ spoofed_content_type: "内容类型与通过内容、文件名和扩展名声明的内容类型不同"
5
6
  file_size_not_less_than: "文件大小必须小于 %{max_size}(当前大小为 %{file_size})"
6
7
  file_size_not_less_than_or_equal_to: "文件大小必须小于或等于 %{max_size}(当前大小为 %{file_size})"
7
8
  file_size_not_greater_than: "文件大小必须大于 %{min_size}(当前大小为 %{file_size})"
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
3
4
  require_relative 'concerns/errorable.rb'
5
+ require_relative 'concerns/metadatable.rb'
4
6
  require_relative 'concerns/symbolizable.rb'
5
- require_relative 'metadata.rb'
6
7
 
7
8
  module ActiveStorageValidations
8
9
  class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
9
- include OptionProcUnfolding
10
+ include ActiveStorageable
10
11
  include Errorable
12
+ include Metadatable
13
+ include OptionProcUnfolding
11
14
  include Symbolizable
12
15
 
13
16
  AVAILABLE_CHECKS = %i[with].freeze
@@ -28,44 +31,17 @@ module ActiveStorageValidations
28
31
  ensure_aspect_ratio_validity
29
32
  end
30
33
 
31
- if Rails.gem_version >= Gem::Version.new('6.0.0')
32
- def validate_each(record, attribute, _value)
33
- return true unless record.send(attribute).attached?
34
-
35
- changes = record.attachment_changes[attribute.to_s]
36
- return true if changes.blank?
34
+ def validate_each(record, attribute, _value)
35
+ return if no_attachments?(record, attribute)
37
36
 
38
- files = Array.wrap(changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable)
39
-
40
- files.each do |file|
41
- metadata = Metadata.new(file).metadata
42
- next if is_valid?(record, attribute, file, metadata)
43
- break
44
- end
45
- end
46
- else
47
- # Rails 5
48
- def validate_each(record, attribute, _value)
49
- return true unless record.send(attribute).attached?
50
-
51
- files = Array.wrap(record.send(attribute))
52
-
53
- files.each do |file|
54
- # Analyze file first if not analyzed to get all required metadata.
55
- file.analyze; file.reload unless file.analyzed?
56
- metadata = file.metadata
57
-
58
- next if is_valid?(record, attribute, file, metadata)
59
- break
60
- end
61
- end
37
+ validate_changed_files_from_metadata(record, attribute)
62
38
  end
63
39
 
64
40
  private
65
41
 
66
- def is_valid?(record, attribute, file, metadata)
42
+ def is_valid?(record, attribute, attachable, metadata)
67
43
  flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
68
- errors_options = initialize_error_options(options, file)
44
+ errors_options = initialize_error_options(options, attachable)
69
45
 
70
46
  if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
71
47
  errors_options[:aspect_ratio] = flat_options[:with]
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
3
4
  require_relative 'concerns/errorable.rb'
4
5
  require_relative 'concerns/symbolizable.rb'
5
6
 
6
7
  module ActiveStorageValidations
7
8
  class AttachedValidator < ActiveModel::EachValidator # :nodoc:
9
+ include ActiveStorageable
8
10
  include Errorable
9
11
  include Symbolizable
10
12
 
@@ -13,14 +15,14 @@ module ActiveStorageValidations
13
15
  def check_validity!
14
16
  %i[allow_nil allow_blank].each do |not_authorized_option|
15
17
  if options.include?(not_authorized_option)
16
- raise ArgumentError, "You cannot pass the :#{not_authorized_option} option to the #{self.class.name.split('::').last.underscore}"
18
+ raise ArgumentError, "You cannot pass the :#{not_authorized_option} option to the #{self.class.to_sym} validator"
17
19
  end
18
20
  end
19
21
  end
20
22
 
21
23
  def validate_each(record, attribute, _value)
22
- return if record.send(attribute).attached? &&
23
- !Array.wrap(record.send(attribute)).all?(&:marked_for_destruction?)
24
+ return if attachments_present?(record, attribute) &&
25
+ will_have_attachments_after_save?(record, attribute)
24
26
 
25
27
  errors_options = initialize_error_options(options)
26
28
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
3
4
  require_relative 'concerns/errorable.rb'
4
5
  require_relative 'concerns/symbolizable.rb'
5
6
 
6
7
  module ActiveStorageValidations
7
8
  class BaseSizeValidator < ActiveModel::EachValidator # :nodoc:
8
- include OptionProcUnfolding
9
+ include ActiveStorageable
9
10
  include Errorable
11
+ include OptionProcUnfolding
10
12
  include Symbolizable
11
13
 
12
14
  delegate :number_to_human_size, to: ActiveSupport::NumberHelper
@@ -0,0 +1,28 @@
1
+ module ActiveStorageValidations
2
+ # ActiveStorageValidations::ActiveStorageable
3
+ #
4
+ # Validator helper methods to make our code more explicit.
5
+ module ActiveStorageable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ # Retrieve either an ActiveStorage::Attached::One or an
11
+ # ActiveStorage::Attached::Many instance depending on attribute definition
12
+ def attached_files(record, attribute)
13
+ Array.wrap(record.send(attribute))
14
+ end
15
+
16
+ def attachments_present?(record, attribute)
17
+ record.send(attribute).attached?
18
+ end
19
+
20
+ def no_attachments?(record, attribute)
21
+ !attachments_present?(record, attribute)
22
+ end
23
+
24
+ def will_have_attachments_after_save?(record, attribute)
25
+ !Array.wrap(record.send(attribute)).all?(&:marked_for_destruction?)
26
+ end
27
+ end
28
+ end
@@ -16,12 +16,11 @@ module ActiveStorageValidations
16
16
  end
17
17
 
18
18
  def add_error(record, attribute, error_type, **errors_options)
19
- type = errors_options[:custom_message].presence || error_type
20
- return if record.errors.added?(attribute, type)
19
+ return if record.errors.added?(attribute, error_type)
21
20
 
22
21
  # You can read https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-add
23
22
  # to better understand how Rails model errors work
24
- record.errors.add(attribute, type, **errors_options)
23
+ record.errors.add(attribute, error_type, **errors_options)
25
24
  end
26
25
 
27
26
  private
@@ -0,0 +1,9 @@
1
+ module ActiveStorageValidations
2
+ module Loggable
3
+ extend ActiveSupport::Concern
4
+
5
+ def logger
6
+ Rails.logger
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ require_relative '../metadata'
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::Metadatable
5
+ #
6
+ # Validator methods for analyzing the attachment metadata.
7
+ module Metadatable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # Loop through the newly submitted attachables to validate them
13
+ def validate_changed_files_from_metadata(record, attribute)
14
+ attachables_from_changes(record, attribute).each do |attachable|
15
+ is_valid?(record, attribute, attachable, Metadata.new(attachable).metadata)
16
+ end
17
+ end
18
+
19
+ # Retrieve an array of newly submitted attachables which are file
20
+ # representations such as ActiveStorage::Blob, ActionDispatch::Http::UploadedFile,
21
+ # Rack::Test::UploadedFile, Hash, String, File or Pathname
22
+ def attachables_from_changes(record, attribute)
23
+ changes = record.attachment_changes[attribute.to_s]
24
+ return [] if changes.blank?
25
+
26
+ Array.wrap(
27
+ changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'concerns/loggable'
4
+ require 'open3'
5
+
6
+ module ActiveStorageValidations
7
+ class ContentTypeSpoofDetector
8
+ class FileCommandLineToolNotInstalledError < StandardError; end
9
+
10
+ include Loggable
11
+
12
+ def initialize(record, attribute, file)
13
+ @record = record
14
+ @attribute = attribute
15
+ @file = file
16
+ end
17
+
18
+ def spoofed?
19
+ if supplied_content_type_vs_open3_analizer_mismatch?
20
+ logger.info "Content Type Spoofing detected for file '#{filename}'. The supplied content type is '#{supplied_content_type}' but the content type discovered using open3 is '#{content_type_from_analyzer}'."
21
+ true
22
+ else
23
+ false
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def filename
30
+ @filename ||= @file.blob.present? && @file.blob.filename.to_s
31
+ end
32
+
33
+ def supplied_content_type
34
+ # We remove potential mime type parameters
35
+ @supplied_content_type ||= @file.blob.present? && @file.blob.content_type.downcase.split(/[;,\s]/, 2).first
36
+ end
37
+
38
+ def io
39
+ @io ||= case @record.public_send(@attribute)
40
+ when ActiveStorage::Attached::One then get_io_from_one
41
+ when ActiveStorage::Attached::Many then get_io_from_many
42
+ end
43
+ end
44
+
45
+ def get_io_from_one
46
+ attachable = @record.attachment_changes[@attribute.to_s].attachable
47
+
48
+ case attachable
49
+ when ActionDispatch::Http::UploadedFile
50
+ attachable.read
51
+ when String
52
+ blob = ActiveStorage::Blob.find_signed!(attachable)
53
+ blob.download
54
+ when ActiveStorage::Blob
55
+ attachable.download
56
+ when Hash
57
+ attachable[:io].read
58
+ end
59
+ end
60
+
61
+ def get_io_from_many
62
+ attachables = @record.attachment_changes[@attribute.to_s].attachables
63
+
64
+ if attachables.all? { |attachable| attachable.is_a?(ActionDispatch::Http::UploadedFile) }
65
+ attachables.find do |uploaded_file|
66
+ checksum = ActiveStorage::Blob.new.send(:compute_checksum_in_chunks, uploaded_file)
67
+ checksum == @file.checksum
68
+ end.read
69
+ elsif attachables.all? { |attachable| attachable.is_a?(String) }
70
+ # It's only possible to pass one String as attachable (not an array of String)
71
+ blob = ActiveStorage::Blob.find_signed!(attachables.first)
72
+ blob.download
73
+ elsif attachables.all? { |attachable| attachable.is_a?(ActiveStorage::Blob) }
74
+ attachables.find { |blob| blob == @file.blob }.download
75
+ elsif attachables.all? { |attachable| attachable.is_a?(Hash) }
76
+ # It's only possible to pass one Hash as attachable (not an array of Hash)
77
+ attachables.first[:io].read
78
+ end
79
+ end
80
+
81
+ def content_type_from_analyzer
82
+ # Using Open3 is a better alternative than Marcel (Marcel::MimeType.for(io))
83
+ # for analyzing content type solely based on the file io
84
+ @content_type_from_analyzer ||= open3_mime_type_for_io
85
+ end
86
+
87
+ def open3_mime_type_for_io
88
+ return nil if io.blank?
89
+
90
+ Tempfile.create do |tempfile|
91
+ tempfile.binmode
92
+ tempfile.write(io)
93
+ tempfile.rewind
94
+
95
+ command = "file -b --mime-type #{tempfile.path}"
96
+ output, status = Open3.capture2(command)
97
+
98
+ if status.success?
99
+ mime_type = output.strip
100
+ return mime_type
101
+ else
102
+ raise "Error determining MIME type: #{output}"
103
+ end
104
+
105
+ rescue Errno::ENOENT
106
+ raise FileCommandLineToolNotInstalledError, 'file command-line tool is not installed'
107
+ end
108
+ end
109
+
110
+ def supplied_content_type_vs_open3_analizer_mismatch?
111
+ supplied_content_type.present? &&
112
+ !supplied_content_type_intersects_content_type_from_analyzer?
113
+ end
114
+
115
+ def supplied_content_type_intersects_content_type_from_analyzer?
116
+ # Ruby intersects? method is only available from 3.1
117
+ enlarged_content_type(supplied_content_type).any? do |item|
118
+ enlarged_content_type(content_type_from_analyzer).include?(item)
119
+ end
120
+ end
121
+
122
+ def enlarged_content_type(content_type)
123
+ [content_type, *parent_content_types(content_type)].compact.uniq
124
+ end
125
+
126
+ def parent_content_types(content_type)
127
+ Marcel::TYPE_PARENTS[content_type] || []
128
+ end
129
+ end
130
+ end