active_storage_validations 0.9.7 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
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