active_storage_validations 0.9.7 → 2.0.2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +737 -229
  3. data/config/locales/da.yml +53 -0
  4. data/config/locales/de.yml +50 -19
  5. data/config/locales/en.yml +50 -19
  6. data/config/locales/es.yml +50 -19
  7. data/config/locales/fr.yml +50 -19
  8. data/config/locales/it.yml +50 -19
  9. data/config/locales/ja.yml +50 -19
  10. data/config/locales/nl.yml +50 -19
  11. data/config/locales/pl.yml +50 -19
  12. data/config/locales/pt-BR.yml +50 -19
  13. data/config/locales/ru.yml +50 -19
  14. data/config/locales/sv.yml +53 -0
  15. data/config/locales/tr.yml +50 -19
  16. data/config/locales/uk.yml +50 -19
  17. data/config/locales/vi.yml +50 -19
  18. data/config/locales/zh-CN.yml +53 -0
  19. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  20. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  21. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +47 -0
  22. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +57 -0
  23. data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
  24. data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
  25. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  26. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  27. data/lib/active_storage_validations/analyzer.rb +87 -0
  28. data/lib/active_storage_validations/aspect_ratio_validator.rb +154 -99
  29. data/lib/active_storage_validations/attached_validator.rb +22 -5
  30. data/lib/active_storage_validations/base_comparison_validator.rb +71 -0
  31. data/lib/active_storage_validations/content_type_validator.rb +206 -25
  32. data/lib/active_storage_validations/dimension_validator.rb +105 -82
  33. data/lib/active_storage_validations/duration_validator.rb +55 -0
  34. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +49 -0
  35. data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
  36. data/lib/active_storage_validations/limit_validator.rb +75 -16
  37. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
  38. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
  39. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +140 -0
  40. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +94 -59
  41. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +97 -55
  42. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  43. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  44. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
  45. data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
  46. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
  47. data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
  48. data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +49 -0
  49. data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
  50. data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
  51. data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
  52. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
  53. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
  54. data/lib/active_storage_validations/matchers.rb +11 -16
  55. data/lib/active_storage_validations/processable_file_validator.rb +37 -0
  56. data/lib/active_storage_validations/railtie.rb +11 -0
  57. data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
  58. data/lib/active_storage_validations/shared/asv_analyzable.rb +80 -0
  59. data/lib/active_storage_validations/shared/asv_attachable.rb +204 -0
  60. data/lib/active_storage_validations/shared/asv_errorable.rb +40 -0
  61. data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
  62. data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
  63. data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
  64. data/lib/active_storage_validations/size_validator.rb +24 -40
  65. data/lib/active_storage_validations/total_size_validator.rb +51 -0
  66. data/lib/active_storage_validations/version.rb +1 -1
  67. data/lib/active_storage_validations.rb +20 -6
  68. metadata +127 -21
  69. data/lib/active_storage_validations/metadata.rb +0 -123
@@ -1,22 +1,53 @@
1
1
  vi:
2
2
  errors:
3
3
  messages:
4
- content_type_invalid: "tệp không hợp lệ"
5
- file_size_out_of_range: "kích thước %{file_size} vượt giới hạn"
6
- limit_out_of_range: "tổng số tệp vượt giới hạn"
7
- image_metadata_missing: "không phải là ảnh"
8
- dimension_min_inclusion: "phải lớn hơn hoặc bằng %{width} x %{height} pixel"
9
- dimension_max_inclusion: "phải nhỏ hơn hoặc bằng %{width} x %{height} pixel"
10
- dimension_width_inclusion: "chiều rộng không nằm trong %{min} %{max} pixel"
11
- dimension_height_inclusion: "chiều cao không nằm trong %{min} %{max} pixel"
12
- dimension_width_greater_than_or_equal_to: "chiều rộng phải lớn hơn hoặc bằng %{length} pixel"
13
- dimension_height_greater_than_or_equal_to: "chiều cao phải lớn hơn hoặc bằng %{length} pixel"
14
- dimension_width_less_than_or_equal_to: "chiều rộng phải nhỏ hơn hoặc bằng %{length} pixel"
15
- dimension_height_less_than_or_equal_to: "chiều cao phải nhỏ hơn hoặc bằng %{length} pixel"
16
- dimension_width_equal_to: "chiều rộng phải bằng %{length} pixel"
17
- dimension_height_equal_to: "chiều cao phải bằng %{length} pixel"
18
- aspect_ratio_not_square: "phải ảnh hình vuông"
19
- aspect_ratio_not_portrait: "phải là ảnh đứng"
20
- aspect_ratio_not_landscape: "phải là ảnh ngang"
21
- aspect_ratio_is_not: "phải tỉ lệ ảnh %{aspect_ratio}"
22
- aspect_ratio_unknown: "tỉ lệ ảnh không xác định"
4
+ content_type_invalid:
5
+ one: " loại nội dung không hợp lệ (loại nội dung được ủy quyền là %{autorized_human_content_types})"
6
+ other: " loại nội dung không hợp lệ (các loại nội dung được ủy quyền là %{autorized_human_content_types})"
7
+ content_type_spoofed:
8
+ one: " một loại nội dung không tương đương với loại được phát hiện thông qua nội dung của nó (loại nội dung được ủy quyền là %{autorized_human_content_types})"
9
+ other: " một loại nội dung không tương đương với loại được phát hiện thông qua nội dung của nó (các loại nội dung được ủy quyền là %{autorized_human_content_types})"
10
+ file_size_not_less_than: "kích thước tệp phải nhỏ hơn %{max} (kích thước hiện tại là %{file_size})"
11
+ file_size_not_less_than_or_equal_to: "kích thước tệp phải nhỏ hơn hoặc bằng %{max} (kích thước hiện tại là %{file_size})"
12
+ file_size_not_greater_than: "kích thước tệp phải lớn hơn %{min} (kích thước hiện tại là %{file_size})"
13
+ file_size_not_greater_than_or_equal_to: "kích thước tệp phải lớn hơn hoặc bằng %{min} (kích thước hiện tại là %{file_size})"
14
+ file_size_not_between: "kích thước tệp phải nằm trong khoảng từ %{min} đến %{max} (kích thước hiện tại là %{file_size})"
15
+ total_file_size_not_less_than: "tổng kích thước tệp phải nhỏ hơn %{max} (kích thước hiện tại là %{total_file_size})"
16
+ total_file_size_not_less_than_or_equal_to: "tổng kích thước tệp phải nhỏ hơn hoặc bằng %{max} (kích thước hiện tại là %{total_file_size})"
17
+ total_file_size_not_greater_than: "tổng kích thước tệp phải lớn hơn %{min} (kích thước hiện tại là %{total_file_size})"
18
+ total_file_size_not_greater_than_or_equal_to: "tổng kích thước tệp phải lớn hơn hoặc bằng %{min} (kích thước hiện tại là %{total_file_size})"
19
+ total_file_size_not_between: "tổng kích thước tệp phải nằm trong khoảng từ %{min} đến %{max} (kích thước hiện tại %{total_file_size})"
20
+ duration_not_less_than: "thời lượng phải nhỏ hơn %{max} (thời lượng hiện tại %{duration})"
21
+ duration_not_less_than_or_equal_to: "thời lượng phải nhỏ hơn hoặc bằng %{max} (thời lượng hiện tại là %{duration})"
22
+ duration_not_greater_than: "thời lượng phải lớn hơn %{min} (thời lượng hiện tại là %{duration})"
23
+ duration_not_greater_than_or_equal_to: "thời lượng phải lớn hơn hoặc bằng %{min} (thời lượng hiện tại là %{duration})"
24
+ duration_not_between: "thời lượng phải nằm trong khoảng từ %{min} và %{max} (thời lượng hiện tại là %{duration})"
25
+ limit_out_of_range:
26
+ zero: "không có tệp nào được đính kèm (phải có giữa các tệp %{min} và %{max})"
27
+ one: "chỉ có 1 tệp được đính kèm (phải có giữa các tệp %{min} và %{max})"
28
+ other: "tổng số tệp phải nằm trong khoảng từ %{min} và %{max} tệp (có các tệp %{count} được đính kèm)"
29
+ limit_min_not_reached:
30
+ zero: "không có tệp nào được đính kèm (phải có ít nhất %{min} tệp)"
31
+ one: "chỉ có 1 tệp được đính kèm (phải có ít nhất %{min} tệp)"
32
+ other: "các tệp %{count} được đính kèm (phải có ít nhất %{min} tệp)"
33
+ limit_max_exceeded:
34
+ zero: "không có tệp nào được đính kèm (tối đa là các tệp %{max})"
35
+ one: "quá nhiều tệp được đính kèm (tối đa là các tệp %{max}, có %{count})"
36
+ other: "quá nhiều tệp được đính kèm (tối đa là các tệp %{max}, có %{count})"
37
+ media_metadata_missing: "không phải là ảnh"
38
+ dimension_min_not_included_in: "phải lớn hơn hoặc bằng %{width} x %{height} pixel"
39
+ dimension_max_not_included_in: "phải nhỏ hơn hoặc bằng %{width} x %{height} pixel"
40
+ dimension_width_not_included_in: "chiều rộng không nằm trong %{min} và %{max} pixel"
41
+ dimension_height_not_included_in: "chiều cao không nằm trong %{min} và %{max} pixel"
42
+ dimension_width_not_greater_than_or_equal_to: "chiều rộng phải lớn hơn hoặc bằng %{length} pixel"
43
+ dimension_height_not_greater_than_or_equal_to: "chiều cao phải lớn hơn hoặc bằng %{length} pixel"
44
+ dimension_width_not_less_than_or_equal_to: "chiều rộng phải nhỏ hơn hoặc bằng %{length} pixel"
45
+ dimension_height_not_less_than_or_equal_to: "chiều cao phải nhỏ hơn hoặc bằng %{length} pixel"
46
+ dimension_width_not_equal_to: "chiều rộng phải bằng %{length} pixel"
47
+ dimension_height_not_equal_to: "chiều cao phải bằng %{length} pixel"
48
+ aspect_ratio_not_square: "phải là hình vuông (tệp hiện tại là %{width}x%{height}px)"
49
+ aspect_ratio_not_portrait: "phải là chân dung (tệp hiện tại là %{width}x%{height}px)"
50
+ aspect_ratio_not_landscape: "phải là ngang (tệp hiện tại là %{width}x%{height}px)"
51
+ aspect_ratio_not_x_y: "phải là %{authorized_aspect_ratios} (tệp hiện tại là %{width}x%{height}px)"
52
+ 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})"
53
+ file_not_processable: "không được xác định là tệp phương tiện hợp lệ"
@@ -0,0 +1,53 @@
1
+ zh-CN:
2
+ errors:
3
+ messages:
4
+ content_type_invalid:
5
+ one: "具有无效的内容类型(授权的内容类型为%{authorized_human_content_types})"
6
+ other: "具有无效的内容类型(授权的内容类型为%{authorized_human_content_types})"
7
+ content_type_spoofed:
8
+ one: "其内容类型不等于通过其内容检测到的内容类型(授权内容类型为%{authorized_human_content_types})"
9
+ other: "其内容类型与通过其内容检测到的内容类型不等效(授权内容类型为%{authorized_human_content_types})"
10
+ file_size_not_less_than: "文件大小必须小于 %{max}(当前大小为 %{file_size})"
11
+ file_size_not_less_than_or_equal_to: "文件大小必须小于或等于 %{max}(当前大小为 %{file_size})"
12
+ file_size_not_greater_than: "文件大小必须大于 %{min}(当前大小为 %{file_size})"
13
+ file_size_not_greater_than_or_equal_to: "文件大小必须大于或等于 %{min}(当前大小为 %{file_size})"
14
+ file_size_not_between: "文件大小必须介于 %{min} 和 %{max} 之间(当前大小为 %{file_size})"
15
+ total_file_size_not_less_than: "总文件大小必须小于 %{max}(当前大小为 %{total_file_size})"
16
+ total_file_size_not_less_than_or_equal_to: "文件总大小必须小于或等于 %{max}(当前大小为 %{total_file_size})"
17
+ total_file_size_not_greater_than: "总文件大小必须大于 %{min}(当前大小为 %{total_file_size})"
18
+ total_file_size_not_greater_than_or_equal_to: "文件总大小必须大于或等于 %{min}(当前大小为 %{total_file_size})"
19
+ total_file_size_not_between: "总文件大小必须介于 %{min} 和 %{max} 之间(当前大小为 %{total_file_size})"
20
+ duration_not_less_than: "持续时间必须小于%{max}(当前持续时间为%{duration})"
21
+ duration_not_less_than_or_equal_to: "持续时间必须小于或等于%{max}(当前持续时间为%{duration})"
22
+ duration_not_greater_than: "持续时间必须大于%{min}(当前持续时间为%{duration})"
23
+ duration_not_greater_than_or_equal_to: "持续时间必须大于或等于%{min}(当前持续时间为%{duration})"
24
+ duration_not_between: "持续时间必须在%{min}和%{max}之间(当前持续时间为%{duration})"
25
+ limit_out_of_range:
26
+ zero: "未附加文件(必须具有%{min}和%{max}文件之间)"
27
+ one: "仅附加1个文件(必须具有%{min}和%{max}文件之间)"
28
+ other: "文件总数必须在%{min}和%{max}文件之间(已连接%{count}文件)"
29
+ limit_min_not_reached:
30
+ zero: "未附加文件(必须至少具有%{min}文件)"
31
+ one: "仅附加1个文件(必须至少具有%{min}文件)"
32
+ other: "%{count}文件已连接(必须至少具有%{min}文件)"
33
+ limit_max_exceeded:
34
+ zero: "未附加文件(最大为%{max}文件)"
35
+ one: "附加的文件太多(最大为%{max}文件,得到%{count})"
36
+ other: "附加的文件太多(最大为%{max}文件,得到%{count})"
37
+ media_metadata_missing: "不是有效的媒体文件"
38
+ dimension_min_not_included_in: "必须大于或等于 %{width} x %{height} 像素"
39
+ dimension_max_not_included_in: "必须小于或等于 %{width} x %{height} 像素"
40
+ dimension_width_not_included_in: "宽度不在 %{min} 和 %{max} 像素之间"
41
+ dimension_height_not_included_in: "高度不在 %{min} 和 %{max} 像素之间"
42
+ dimension_width_not_greater_than_or_equal_to: "宽度必须大于或等于 %{length} 像素"
43
+ dimension_height_not_greater_than_or_equal_to: "高度必须大于或等于 %{length} 像素"
44
+ dimension_width_not_less_than_or_equal_to: "宽度必须小于或等于 %{length} 像素"
45
+ dimension_height_not_less_than_or_equal_to: "高度必须小于或等于 %{length} 像素"
46
+ dimension_width_not_equal_to: "宽度必须等于 %{length} 像素"
47
+ dimension_height_not_equal_to: "高度必须等于 %{length} 像素"
48
+ aspect_ratio_not_square: "必须为正方形(当前文件为 %{width} x%{height} 像素)"
49
+ aspect_ratio_not_portrait: "必须是纵向的(当前文件为 %{width} x%{height} 像素)"
50
+ aspect_ratio_not_landscape: "必须是横向(当前文件为 %{width} x%{height} 像素)"
51
+ expect_ratio_not_x_y: "必须为 %{authorized_aspect_ratios}(当前文件为 %{width} x%{height} 像素)"
52
+ expect_ratio_not_in_list: "具有无效的长宽比(有效长宽比为 %{authorized_aspect_ratios})"
53
+ file_not_processable: "未标识为有效的媒体文件"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative 'shared/asv_ff_probable'
5
+
6
+ module ActiveStorageValidations
7
+ # = ActiveStorageValidations Audio \Analyzer
8
+ #
9
+ # Extracts the following from an audio attachable:
10
+ #
11
+ # * Duration (seconds)
12
+ # * Bit rate (bits/s)
13
+ # * Sample rate (hertz)
14
+ # * Tags (internal metadata)
15
+ #
16
+ # Example:
17
+ #
18
+ # ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable).metadata
19
+ # # => { duration: 5.0, bit_rate: 320340, sample_rate: 44100, tags: { encoder: "Lavc57.64", ... } }
20
+ #
21
+ # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
22
+ class Analyzer::AudioAnalyzer < Analyzer
23
+ include ASVFFProbable
24
+
25
+ def metadata
26
+ read_media do |media|
27
+ {
28
+ duration: duration,
29
+ bit_rate: bit_rate,
30
+ sample_rate: sample_rate,
31
+ tags: tags
32
+ }.compact
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def duration
39
+ duration = audio_stream["duration"]
40
+ Float(duration).round(1) if duration
41
+ end
42
+
43
+ def bit_rate
44
+ bit_rate = audio_stream["bit_rate"]
45
+ Integer(bit_rate) if bit_rate
46
+ end
47
+
48
+ def sample_rate
49
+ sample_rate = audio_stream["sample_rate"]
50
+ Integer(sample_rate) if sample_rate
51
+ end
52
+
53
+ def tags
54
+ tags = audio_stream["tags"]
55
+ Hash(tags) if tags
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module ActiveStorageValidations
6
+ # = ActiveStorageValidations ContentType \Analyzer
7
+ #
8
+ # Extracts the content type from an attachable. This is used to prevent content
9
+ # type spoofing.
10
+ #
11
+ # Example:
12
+ #
13
+ # ActiveStorageValidations::Analyzer::ContentTypeAnalyzer.new(attachable).content_type
14
+ # # => { content_type: "image/png" }
15
+ #
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.
17
+ class Analyzer::ContentTypeAnalyzer < Analyzer
18
+ class FileCommandLineToolNotInstalledError < StandardError; end
19
+
20
+ def content_type
21
+ read_media do |media|
22
+ {
23
+ content_type: media
24
+ }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def read_media
31
+ Tempfile.create(binmode: true) do |tempfile|
32
+ begin
33
+ if media(tempfile).present?
34
+ yield media(tempfile)
35
+ else
36
+ logger.info "Skipping file content_type analysis because Linux file command doesn't support the file"
37
+ nil
38
+ end
39
+ ensure
40
+ tempfile.close
41
+ end
42
+ end
43
+ rescue Errno::ENOENT
44
+ raise FileCommandLineToolNotInstalledError, 'file command-line tool is not installed'
45
+ end
46
+
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
+ )
55
+
56
+ status.success? ? stdout.strip : nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem.
5
+ # MiniMagick requires the {ImageMagick}[http://www.imagemagick.org] system library.
6
+ # This is the default Rails image analyzer.
7
+ class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer
8
+
9
+ private
10
+
11
+ def read_media
12
+ Tempfile.create(binmode: true) do |tempfile|
13
+ begin
14
+ if media(tempfile).valid?
15
+ yield media(tempfile)
16
+ else
17
+ logger.info "Skipping image analysis because ImageMagick doesn't support the file"
18
+ {}
19
+ end
20
+ ensure
21
+ tempfile.close
22
+ end
23
+ end
24
+ rescue MiniMagick::Error => error
25
+ logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
26
+ {}
27
+ end
28
+
29
+ def media_from_path(path)
30
+ instrument("mini_magick") do
31
+ MiniMagick::Image.new(path)
32
+ end
33
+ end
34
+
35
+ def rotated_image?(image)
36
+ %w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
37
+ end
38
+
39
+ def supported?
40
+ require "mini_magick"
41
+ true
42
+ rescue LoadError
43
+ logger.info "Skipping image analysis because the mini_magick gem isn't installed"
44
+ false
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem.
5
+ # Ruby-vips requires the {libvips}[https://libvips.github.io/libvips/] system library.
6
+ class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer
7
+
8
+ private
9
+
10
+ def read_media
11
+ Tempfile.create(binmode: true) do |tempfile|
12
+ begin
13
+ if media(tempfile)
14
+ yield media(tempfile)
15
+ else
16
+ logger.info "Skipping image analysis because Vips doesn't support the file"
17
+ {}
18
+ end
19
+ ensure
20
+ tempfile.close
21
+ end
22
+ end
23
+ rescue ::Vips::Error => error
24
+ logger.error "Skipping image analysis due to a Vips error: #{error.message}"
25
+ {}
26
+ end
27
+
28
+ def media_from_path(path)
29
+ instrument("vips") do
30
+ begin
31
+ ::Vips::Image.new_from_file(path, access: :sequential)
32
+ rescue ::Vips::Error
33
+ # Vips throw errors rather than returning false when reading a not
34
+ # supported attachable.
35
+ # We stumbled upon this issue while reading 0 byte size attachable
36
+ # https://github.com/janko/image_processing/issues/97
37
+ nil
38
+ end
39
+ end
40
+ end
41
+
42
+ ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
43
+ def rotated_image?(image)
44
+ ROTATIONS === image.get("exif-ifd0-Orientation")
45
+ rescue ::Vips::Error
46
+ false
47
+ end
48
+
49
+ def supported?
50
+ require "vips"
51
+ true
52
+ rescue LoadError
53
+ logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
54
+ false
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # = ActiveStorageValidations Image \Analyzer
5
+ #
6
+ # This is an abstract base class for image analyzers, which extract width and height from an image attachable.
7
+ #
8
+ # If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience.
9
+ #
10
+ # Example:
11
+ #
12
+ # ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
13
+ # # => { width: 4104, height: 2736 }
14
+ class Analyzer::ImageAnalyzer < Analyzer
15
+ @@supported_analyzers = {}
16
+
17
+ def metadata
18
+ return {} unless analyzer_supported?
19
+
20
+ read_media do |media|
21
+ if rotated_image?(media)
22
+ { width: media.height, height: media.width }
23
+ else
24
+ { width: media.width, height: media.height }
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def analyzer_supported?
32
+ if @@supported_analyzers.key?(self)
33
+ @@supported_analyzers.fetch(self)
34
+ else
35
+ @@supported_analyzers[self] = supported?
36
+ end
37
+ end
38
+
39
+ # Override this method in a concrete subclass. Have it return true if the image is rotated.
40
+ def rotated_image?(media)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # Override this method in a concrete subclass. Have it return true if the analyzer is supported.
45
+ def supported?
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # = ActiveStorageValidations Null Analyzer
5
+ #
6
+ # This is a fallback analyzer when the attachable media type is not supported
7
+ # by our gem.
8
+ #
9
+ # Example:
10
+ #
11
+ # ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable).metadata
12
+ # # => {}
13
+ class Analyzer::NullAnalyzer < Analyzer
14
+ def metadata
15
+ {}
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations:::ASVFFProbable
5
+ #
6
+ # Validator helper methods for analyzers using FFprobe.
7
+ module ASVFFProbable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ def read_media
13
+ Tempfile.create(binmode: true) do |tempfile|
14
+ begin
15
+ if media(tempfile).present?
16
+ yield media(tempfile)
17
+ else
18
+ logger.info "Skipping file metadata analysis because ffprobe doesn't support the file"
19
+ {}
20
+ end
21
+ ensure
22
+ tempfile.close
23
+ end
24
+ end
25
+ rescue Errno::ENOENT
26
+ logger.info "Skipping file metadata analysis because ffprobe isn't installed"
27
+ {}
28
+ end
29
+
30
+ def media_from_path(path)
31
+ instrument(File.basename(ffprobe_path)) do
32
+ stdout, _stderr, status = Open3.capture3(
33
+ ffprobe_path,
34
+ "-print_format", "json",
35
+ "-show_streams",
36
+ "-show_format",
37
+ "-v", "error",
38
+ path
39
+ )
40
+
41
+ status.success? ? JSON.parse(stdout) : nil
42
+ end
43
+ end
44
+
45
+ def ffprobe_path
46
+ ActiveStorage.paths[:ffprobe] || "ffprobe"
47
+ end
48
+
49
+ def video_stream
50
+ @video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
51
+ end
52
+
53
+ def audio_stream
54
+ @audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {}
55
+ end
56
+
57
+ def streams
58
+ @streams ||= @media["streams"] || []
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative 'shared/asv_ff_probable'
5
+
6
+ module ActiveStorageValidations
7
+ # = ActiveStorageValidations Video \Analyzer
8
+ #
9
+ # Extracts the following from a video attachable:
10
+ #
11
+ # * Width (pixels)
12
+ # * Height (pixels)
13
+ # * Duration (seconds)
14
+ # * Angle (degrees)
15
+ # * Audio (true if file has an audio channel, false if not)
16
+ # * Video (true if file has an video channel, false if not)
17
+ #
18
+ # Example:
19
+ #
20
+ # ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable).metadata
21
+ # # => { width: 640, height: 480, duration: 5.0, angle: 0, audio: true, video: true }
22
+ #
23
+ # When a video's angle is 90, -90, 270 or -270 degrees, its width and height are automatically swapped for convenience.
24
+ #
25
+ # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
26
+ class Analyzer::VideoAnalyzer < Analyzer
27
+ include ASVFFProbable
28
+
29
+ def metadata
30
+ read_media do |media|
31
+ {
32
+ width: (Integer(width) if width),
33
+ height: (Integer(height) if height),
34
+ duration: duration,
35
+ angle: angle,
36
+ audio: audio?,
37
+ video: video?
38
+ }.compact
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def width
45
+ if rotated?
46
+ computed_height || encoded_height
47
+ else
48
+ encoded_width
49
+ end
50
+ end
51
+
52
+ def height
53
+ if rotated?
54
+ encoded_width
55
+ else
56
+ computed_height || encoded_height
57
+ end
58
+ end
59
+
60
+ def duration
61
+ duration = video_stream["duration"] || container["duration"]
62
+ Float(duration).round(1) if duration
63
+ end
64
+
65
+ def angle
66
+ if tags["rotate"]
67
+ Integer(tags["rotate"])
68
+ elsif display_matrix && display_matrix["rotation"]
69
+ Integer(display_matrix["rotation"])
70
+ end
71
+ end
72
+
73
+ def display_matrix
74
+ side_data.detect { |data| data["side_data_type"] == "Display Matrix" }
75
+ end
76
+
77
+ def display_aspect_ratio
78
+ if descriptor = video_stream["display_aspect_ratio"]
79
+ if terms = descriptor.split(":", 2)
80
+ numerator = Integer(terms[0])
81
+ denominator = Integer(terms[1])
82
+
83
+ [numerator, denominator] unless numerator == 0
84
+ end
85
+ end
86
+ end
87
+
88
+ def rotated?
89
+ angle == 90 || angle == 270 || angle == -90 || angle == -270
90
+ end
91
+
92
+ def audio?
93
+ audio_stream.present?
94
+ end
95
+
96
+ def video?
97
+ video_stream.present?
98
+ end
99
+
100
+ def computed_height
101
+ if encoded_width && display_height_scale
102
+ encoded_width * display_height_scale
103
+ end
104
+ end
105
+
106
+ def encoded_width
107
+ @encoded_width ||= Float(video_stream["width"]) if video_stream["width"]
108
+ end
109
+
110
+ def encoded_height
111
+ @encoded_height ||= Float(video_stream["height"]) if video_stream["height"]
112
+ end
113
+
114
+ def display_height_scale
115
+ @display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio
116
+ end
117
+
118
+ def tags
119
+ @tags ||= video_stream["tags"] || {}
120
+ end
121
+
122
+ def side_data
123
+ @side_data ||= video_stream["side_data_list"] || {}
124
+ end
125
+
126
+ def container
127
+ @container ||= @media["format"] || {}
128
+ end
129
+ end
130
+ end