active_storage_validations 3.0.5 → 4.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +227 -116
  4. data/Rakefile +13 -15
  5. data/config/locales/da.yml +2 -0
  6. data/config/locales/de.yml +2 -0
  7. data/config/locales/en-GB.yml +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/config/locales/es.yml +2 -0
  10. data/config/locales/fr.yml +2 -0
  11. data/config/locales/it.yml +2 -0
  12. data/config/locales/ja.yml +2 -0
  13. data/config/locales/nl.yml +2 -0
  14. data/config/locales/pl.yml +2 -0
  15. data/config/locales/pt-BR.yml +2 -0
  16. data/config/locales/ru.yml +2 -0
  17. data/config/locales/sv.yml +2 -0
  18. data/config/locales/tr.yml +2 -0
  19. data/config/locales/uk.yml +2 -0
  20. data/config/locales/vi.yml +2 -0
  21. data/config/locales/zh-CN.yml +2 -0
  22. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +0 -1
  23. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +31 -0
  24. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +44 -0
  25. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +15 -24
  26. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +41 -7
  27. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +36 -10
  28. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +3 -9
  29. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +5 -4
  30. data/lib/active_storage_validations/analyzer/video_analyzer.rb +0 -1
  31. data/lib/active_storage_validations/analyzer.rb +35 -3
  32. data/lib/active_storage_validations/content_type_validator.rb +24 -6
  33. data/lib/active_storage_validations/dimension_validator.rb +4 -2
  34. data/lib/active_storage_validations/form_builder.rb +43 -0
  35. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +8 -0
  36. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +4 -0
  37. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -1
  38. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +12 -0
  39. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +8 -0
  40. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +8 -0
  41. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +4 -0
  42. data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +8 -0
  43. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +8 -0
  44. data/lib/active_storage_validations/matchers/shared/asv_except_onable.rb +57 -0
  45. data/lib/active_storage_validations/matchers/shared/asv_spoofing_protectable.rb +44 -0
  46. data/lib/active_storage_validations/matchers/shared/asv_timeoutable.rb +32 -0
  47. data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
  48. data/lib/active_storage_validations/matchers.rb +10 -1
  49. data/lib/active_storage_validations/processable_file_validator.rb +60 -1
  50. data/lib/active_storage_validations/railtie.rb +6 -0
  51. data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
  52. data/lib/active_storage_validations/shared/asv_attachable.rb +9 -10
  53. data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
  54. data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
  55. data/lib/active_storage_validations/version.rb +1 -1
  56. data/lib/active_storage_validations/with_audio_validator.rb +37 -0
  57. data/lib/active_storage_validations.rb +23 -0
  58. metadata +27 -58
data/Rakefile CHANGED
@@ -1,29 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  begin
4
- require 'bundler/setup'
4
+ require "bundler/setup"
5
5
  rescue LoadError
6
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
7
  end
8
8
 
9
- require 'rdoc/task'
9
+ require "rdoc/task"
10
10
 
11
11
  RDoc::Task.new(:rdoc) do |rdoc|
12
- rdoc.rdoc_dir = 'rdoc'
13
- rdoc.title = 'ActiveStorageValidations'
14
- rdoc.options << '--line-numbers'
15
- rdoc.rdoc_files.include('README.md')
16
- rdoc.rdoc_files.include('lib/**/*.rb')
12
+ rdoc.rdoc_dir = "rdoc"
13
+ rdoc.title = "ActiveStorageValidations"
14
+ rdoc.options << "--line-numbers"
15
+ rdoc.rdoc_files.include("README.md")
16
+ rdoc.rdoc_files.include("lib/**/*.rb")
17
17
  end
18
18
 
19
- require 'bundler/gem_tasks'
19
+ require "bundler/gem_tasks"
20
20
 
21
- require 'rake/testtask'
21
+ require "rspec/core/rake_task"
22
22
 
23
- Rake::TestTask.new(:test) do |t|
24
- t.libs << 'test'
25
- t.pattern = 'test/**/*_test.rb'
26
- t.verbose = false
23
+ RSpec::Core::RakeTask.new(:spec) do |t|
24
+ t.pattern = "spec/**/*_spec.rb"
27
25
  end
28
26
 
29
- task default: :test
27
+ task default: :spec
@@ -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})"
@@ -55,6 +55,8 @@ sv:
55
55
  aspect_ratio_not_x_y: "måste vara% { authorized_aspect_ratios } (nuvarande fil är %{width}x%{height} pixlar)"
56
56
  aspect_ratio_invalid: "har ett ogiltigt bildförhållande (giltiga bildförhållanden är %{förväntat_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})"
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
3
  require_relative "shared/asv_ff_probable"
5
4
 
6
5
  module ActiveStorageValidations
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # Content-type sniffer using the UNIX {file}[https://en.wikipedia.org/wiki/File_(command)] command.
5
+ #
6
+ # Override the binary path with +ActiveStorage.paths[:file]+.
7
+ class Analyzer::ContentTypeAnalyzer::File < Analyzer::ContentTypeAnalyzer
8
+ class CommandLineToolNotInstalledError < StandardError; end
9
+
10
+ private
11
+
12
+ def backend
13
+ :file
14
+ end
15
+
16
+ def media_from_path(path)
17
+ instrument("file") do |payload|
18
+ result = run_command(file_path, "-b", "--mime-type", path, payload: payload)
19
+ result.success? ? result.stdout.strip : nil
20
+ end
21
+ end
22
+
23
+ def file_path
24
+ ActiveStorage.paths[:file] || "file"
25
+ end
26
+
27
+ def missing_backend_error
28
+ CommandLineToolNotInstalledError.new("file command-line tool is not installed")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveStorageValidations
6
+ # Content-type sniffer using the Google {Magika}[https://github.com/google/magika] CLI.
7
+ #
8
+ # Override the binary path with +ActiveStorage.paths[:magika]+.
9
+ class Analyzer::ContentTypeAnalyzer::Magika < Analyzer::ContentTypeAnalyzer
10
+ class CommandLineToolNotInstalledError < StandardError; end
11
+
12
+ private
13
+
14
+ def backend
15
+ :magika
16
+ end
17
+
18
+ def media_from_path(path)
19
+ instrument("magika") do |payload|
20
+ result = run_command(magika_path, "--json", path, payload: payload)
21
+ result.success? ? mime_type_from_magika_json(result.stdout) : nil
22
+ end
23
+ end
24
+
25
+ def mime_type_from_magika_json(stdout)
26
+ parsed = JSON.parse(stdout)
27
+ entry = parsed.is_a?(Array) ? parsed.first : parsed
28
+ return nil unless entry.is_a?(Hash)
29
+ return nil unless entry.dig("result", "status") == "ok"
30
+
31
+ entry.dig("result", "value", "output", "mime_type").presence
32
+ rescue JSON::ParserError
33
+ nil
34
+ end
35
+
36
+ def magika_path
37
+ ActiveStorage.paths[:magika] || "magika"
38
+ end
39
+
40
+ def missing_backend_error
41
+ CommandLineToolNotInstalledError.new("magika command-line tool is not installed")
42
+ end
43
+ end
44
+ end
@@ -1,26 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module ActiveStorageValidations
6
4
  # = ActiveStorageValidations ContentType \Analyzer
7
5
  #
8
- # Extracts the content type from an attachable. This is used to prevent content
9
- # type spoofing.
10
- #
11
- # Example:
6
+ # Abstract base for content-type sniffers used by +spoofing_protection+.
7
+ # Concrete backends:
12
8
  #
13
- # ActiveStorageValidations::Analyzer::ContentTypeAnalyzer.new(attachable).content_type
14
- # # => { content_type: "image/png" }
9
+ # ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::File.new(attachable).content_type
10
+ # # => { content_type: "image/png", content_type_backend: "file" }
15
11
  #
16
- # This analyzer requires the {UNIX file}[https://en.wikipedia.org/wiki/File_(command)] command, which is not provided by \Rails. While it is available on most UNIX distributions, it may need to be installed explicitly on minimal or custom setups.
12
+ # ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::Magika.new(attachable).content_type
13
+ # # => { content_type: "image/png", content_type_backend: "magika" }
17
14
  class Analyzer::ContentTypeAnalyzer < Analyzer
18
- class FileCommandLineToolNotInstalledError < StandardError; end
19
-
20
15
  def content_type
21
16
  read_media do |media|
22
17
  {
23
- content_type: media
18
+ content_type: media,
19
+ content_type_backend: backend.to_s
24
20
  }
25
21
  end
26
22
  end
@@ -33,7 +29,7 @@ module ActiveStorageValidations
33
29
  if media(tempfile).present?
34
30
  yield media(tempfile)
35
31
  else
36
- logger.info "Skipping file content_type analysis because Linux file command doesn't support the file"
32
+ logger.info "Skipping content_type analysis because #{backend} doesn't support the file"
37
33
  nil
38
34
  end
39
35
  ensure
@@ -41,20 +37,15 @@ module ActiveStorageValidations
41
37
  end
42
38
  end
43
39
  rescue Errno::ENOENT
44
- raise FileCommandLineToolNotInstalledError, "file command-line tool is not installed"
40
+ raise missing_backend_error
45
41
  end
46
42
 
47
- def media_from_path(path)
48
- instrument("file") do
49
- stdout, status = Open3.capture2(
50
- "file",
51
- "-b",
52
- "--mime-type",
53
- path
54
- )
43
+ def backend
44
+ raise NotImplementedError
45
+ end
55
46
 
56
- status.success? ? stdout.strip : nil
57
- end
47
+ def missing_backend_error
48
+ raise NotImplementedError
58
49
  end
59
50
  end
60
51
  end
@@ -1,17 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem.
4
+ # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem
5
+ # only to resolve the ImageMagick / GraphicsMagick +identify+ argv. Commands are executed through
6
+ # {ASVCommandable#run_command} so +command_timeout+ can kill hung processes.
5
7
  # MiniMagick requires the {ImageMagick}[http://www.imagemagick.org] system library.
6
8
  # This is the default Rails image analyzer.
7
9
  class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer
10
+ ImageInfo = Struct.new(:width, :height, :orientation, keyword_init: true) do
11
+ def valid?
12
+ width.to_i.positive? && height.to_i.positive?
13
+ end
14
+
15
+ def [](key)
16
+ orientation if key == "%[orientation]"
17
+ end
18
+ end
19
+
8
20
  private
9
21
 
10
22
  def read_media
11
23
  Tempfile.create(binmode: true) do |tempfile|
12
24
  begin
13
- if media(tempfile).valid?
14
- yield media(tempfile)
25
+ info = media(tempfile)
26
+ if info&.valid?
27
+ yield info
15
28
  else
16
29
  logger.info "Skipping image analysis because ImageMagick doesn't support the file"
17
30
  {}
@@ -20,17 +33,38 @@ module ActiveStorageValidations
20
33
  tempfile.close
21
34
  end
22
35
  end
23
- rescue MiniMagick::Error => error
24
- logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
36
+ rescue Errno::ENOENT
37
+ logger.info "Skipping image analysis because ImageMagick isn't installed"
25
38
  {}
26
39
  end
27
40
 
28
41
  def media_from_path(path)
29
- instrument("mini_magick") do
30
- MiniMagick::Image.new(path)
42
+ instrument("mini_magick") do |payload|
43
+ result = run_command(*identify_command(path), payload: payload)
44
+ return nil unless result.success?
45
+
46
+ parse_identify_output(result.stdout)
31
47
  end
32
48
  end
33
49
 
50
+ def identify_command(path)
51
+ require "mini_magick"
52
+
53
+ tool = ::MiniMagick::Tool.new("identify")
54
+ tool.format("%w\t%h\t%[orientation]")
55
+ tool << path
56
+ tool.command
57
+ end
58
+
59
+ def parse_identify_output(stdout)
60
+ width, height, orientation = stdout.to_s.strip.split("\t", 3)
61
+ ImageInfo.new(
62
+ width: width.to_i,
63
+ height: height.to_i,
64
+ orientation: orientation.to_s
65
+ )
66
+ end
67
+
34
68
  def rotated_image?(image)
35
69
  %w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
36
70
  end
@@ -16,6 +16,9 @@ module ActiveStorageValidations
16
16
  {}
17
17
  end
18
18
  ensure
19
+ # Best-effort: avoid unlinking the tempfile while a timed-out FFI load
20
+ # may still be using the path.
21
+ wait_for_vips_load_thread
19
22
  tempfile.close
20
23
  end
21
24
  end
@@ -25,19 +28,42 @@ module ActiveStorageValidations
25
28
  end
26
29
 
27
30
  def media_from_path(path)
28
- instrument("vips") do
29
- begin
30
- ::Vips::Image.new_from_file(path, access: :sequential)
31
- rescue ::Vips::Error
32
- # Vips throw errors rather than returning false when reading a not
33
- # supported attachable.
34
- # We stumbled upon this issue while reading 0 byte size attachable
35
- # https://github.com/janko/image_processing/issues/97
36
- nil
37
- end
31
+ instrument("vips") do |payload|
32
+ load_vips_image(path, payload)
33
+ end
34
+ end
35
+
36
+ def load_vips_image(path, payload)
37
+ timeout = timeout_in_seconds
38
+ return open_vips_image(path) if timeout.nil?
39
+
40
+ result = nil
41
+ @vips_load_thread = Thread.new { result = open_vips_image(path) }
42
+
43
+ if @vips_load_thread.join(timeout)
44
+ result
45
+ else
46
+ # libvips work may continue in the background until the C call returns.
47
+ mark_timed_out!(payload, "vips")
48
+ nil
38
49
  end
39
50
  end
40
51
 
52
+ def wait_for_vips_load_thread
53
+ return unless @vips_load_thread&.alive?
54
+
55
+ @vips_load_thread.join(0.5)
56
+ end
57
+
58
+ # Returns nil for unsupported / empty files instead of raising.
59
+ # Vips raises on unreadable input (e.g. 0-byte files) rather than returning
60
+ # a falsy image — see https://github.com/janko/image_processing/issues/97
61
+ def open_vips_image(path)
62
+ ::Vips::Image.new_from_file(path, access: :sequential)
63
+ rescue ::Vips::Error
64
+ nil
65
+ end
66
+
41
67
  ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
42
68
  def rotated_image?(image)
43
69
  ROTATIONS === image.get("exif-ifd0-Orientation")
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module ActiveStorageValidations
6
4
  # = ActiveStorageValidations PDF \Analyzer
7
5
  #
@@ -49,13 +47,9 @@ module ActiveStorageValidations
49
47
  end
50
48
 
51
49
  def media_from_path(path)
52
- instrument(File.basename(pdfinfo_path)) do
53
- stdout, _stderr, status = Open3.capture3(
54
- pdfinfo_path,
55
- path
56
- )
57
-
58
- status.success? ? stdout_to_hash(stdout) : nil
50
+ instrument(File.basename(pdfinfo_path)) do |payload|
51
+ result = run_command(pdfinfo_path, path, payload: payload)
52
+ result.success? ? stdout_to_hash(result.stdout) : nil
59
53
  end
60
54
  end
61
55
 
@@ -28,17 +28,18 @@ module ActiveStorageValidations
28
28
  end
29
29
 
30
30
  def media_from_path(path)
31
- instrument(File.basename(ffprobe_path)) do
32
- stdout, _stderr, status = Open3.capture3(
31
+ instrument(File.basename(ffprobe_path)) do |payload|
32
+ result = run_command(
33
33
  ffprobe_path,
34
34
  "-print_format", "json",
35
35
  "-show_streams",
36
36
  "-show_format",
37
37
  "-v", "error",
38
- path
38
+ path,
39
+ payload: payload
39
40
  )
40
41
 
41
- status.success? ? JSON.parse(stdout) : nil
42
+ result.success? ? JSON.parse(result.stdout) : nil
42
43
  end
43
44
  end
44
45
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
3
  require_relative "shared/asv_ff_probable"
5
4
 
6
5
  module ActiveStorageValidations
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shared/asv_attachable"
4
+ require_relative "shared/asv_commandable"
4
5
  require_relative "shared/asv_loggable"
5
6
 
6
7
  module ActiveStorageValidations
@@ -12,12 +13,14 @@ module ActiveStorageValidations
12
13
  # Heavily (not to say 100%) inspired by Rails own ActiveStorage::Analyzer
13
14
  class Analyzer
14
15
  include ASVAttachable
16
+ include ASVCommandable
15
17
  include ASVLoggable
16
18
 
17
19
  attr_reader :attachable
18
20
 
19
- def initialize(attachable)
21
+ def initialize(attachable, timeout: ActiveStorageValidations.command_timeout)
20
22
  @attachable = attachable
23
+ @timeout = timeout
21
24
  end
22
25
 
23
26
  # Override this method in a concrete subclass. Have it return a String content type.
@@ -81,8 +84,37 @@ module ActiveStorageValidations
81
84
  raise NotImplementedError
82
85
  end
83
86
 
84
- def instrument(analyzer, &block)
85
- ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
87
+ def timeout_in_seconds
88
+ return nil if @timeout.nil?
89
+
90
+ @timeout.to_f
91
+ end
92
+
93
+ def instrument(analyzer)
94
+ payload = {
95
+ analyzer: analyzer,
96
+ command: analyzer,
97
+ timeout: timeout_in_seconds,
98
+ timed_out: false
99
+ }
100
+
101
+ ActiveSupport::Notifications.instrument("analyze.active_storage_validations", payload) do
102
+ started_at = monotonic_time
103
+ result = yield payload
104
+ payload[:duration] = monotonic_time - started_at
105
+ result
106
+ end
107
+ end
108
+
109
+ def mark_timed_out!(payload, command)
110
+ payload[:timed_out] = true if payload
111
+ ActiveSupport::Notifications.instrument(
112
+ "timeout.active_storage_validations",
113
+ analyzer: payload&.[](:analyzer) || command,
114
+ command: command,
115
+ timeout: timeout_in_seconds
116
+ )
117
+ logger.info "Skipping file analysis because #{command} timed out after #{timeout_in_seconds} seconds"
86
118
  end
87
119
  end
88
120
  end