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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared/asv_attachable'
4
+ require_relative 'shared/asv_loggable'
5
+
6
+ module ActiveStorageValidations
7
+ # = Active Storage Validations \Analyzer
8
+ #
9
+ # This is an abstract base class for analyzers, which extract metadata from attachables.
10
+ # See ActiveStorageValidations::Analyzer::VideoAnalyzer for an example of a concrete subclass.
11
+ #
12
+ # Heavily (not to say 100%) inspired by Rails own ActiveStorage::Analyzer
13
+ class Analyzer
14
+ include ASVAttachable
15
+ include ASVLoggable
16
+
17
+ attr_reader :attachable
18
+
19
+ def initialize(attachable)
20
+ @attachable = attachable
21
+ end
22
+
23
+ # Override this method in a concrete subclass. Have it return a String content type.
24
+ def content_type
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # Override this method in a concrete subclass. Have it return a Hash of metadata.
29
+ def metadata
30
+ raise NotImplementedError
31
+ end
32
+
33
+ private
34
+
35
+ # Override this method in a concrete subclass. Have it yield a media object.
36
+ def read_media
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def media(tempfile)
41
+ @media ||= case @attachable
42
+ when ActiveStorage::Blob, String
43
+ blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
44
+ media_from_tempfile_path(tempfile, blob)
45
+ when Hash
46
+ io = @attachable[:io]
47
+ if io.is_a?(StringIO)
48
+ media_from_tempfile_path(tempfile, io)
49
+ else
50
+ File.open(io) do |file|
51
+ media_from_path(file.path)
52
+ end
53
+ end
54
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
55
+ media_from_path(@attachable.path)
56
+ when File
57
+ supports_file_attachment? ? media_from_path(@attachable.path) : raise_rails_like_error(@attachable)
58
+ when Pathname
59
+ supports_pathname_attachment? ? media_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
60
+ else
61
+ raise_rails_like_error(@attachable)
62
+ end
63
+ end
64
+
65
+ def media_from_tempfile_path(tempfile, file_representation)
66
+ if file_representation.is_a?(ActiveStorage::Blob)
67
+ file_representation.download { |chunk| tempfile.write(chunk) }
68
+ else
69
+ IO.copy_stream(file_representation, tempfile)
70
+ file_representation.rewind
71
+ end
72
+
73
+ tempfile.flush
74
+ tempfile.rewind
75
+ media_from_path(tempfile.path)
76
+ end
77
+
78
+ # Override this method in a concrete subclass. Have it return a media object.
79
+ def media_from_path(path)
80
+ raise NotImplementedError
81
+ end
82
+
83
+ def instrument(analyzer, &block)
84
+ ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
85
+ end
86
+ end
87
+ end
@@ -1,99 +1,154 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'metadata.rb'
4
-
5
- module ActiveStorageValidations
6
- class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
7
- AVAILABLE_CHECKS = %i[with].freeze
8
- PRECISION = 3
9
-
10
- def initialize(options)
11
- super(options)
12
- end
13
-
14
-
15
- def check_validity!
16
- return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
17
- raise ArgumentError, 'You must pass "aspect_ratio: :OPTION" option to the validator'
18
- end
19
-
20
- if Rails.gem_version >= Gem::Version.new('6.0.0')
21
- def validate_each(record, attribute, _value)
22
- return true unless record.send(attribute).attached?
23
-
24
- changes = record.attachment_changes[attribute.to_s]
25
- return true if changes.blank?
26
-
27
- files = Array.wrap(changes.is_a?(ActiveStorage::Attached::Changes::CreateMany) ? changes.attachables : changes.attachable)
28
-
29
- files.each do |file|
30
- metadata = Metadata.new(file).metadata
31
- next if is_valid?(record, attribute, metadata)
32
- break
33
- end
34
- end
35
- else
36
- # Rails 5
37
- def validate_each(record, attribute, _value)
38
- return true unless record.send(attribute).attached?
39
-
40
- files = Array.wrap(record.send(attribute))
41
-
42
- files.each do |file|
43
- # Analyze file first if not analyzed to get all required metadata.
44
- file.analyze; file.reload unless file.analyzed?
45
- metadata = file.metadata
46
-
47
- next if is_valid?(record, attribute, metadata)
48
- break
49
- end
50
- end
51
- end
52
-
53
-
54
- private
55
-
56
-
57
- def is_valid?(record, attribute, metadata)
58
- if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
59
- add_error(record, attribute, options[:message].presence || :image_metadata_missing)
60
- return false
61
- end
62
-
63
- case options[:with]
64
- when :square
65
- return true if metadata[:width] == metadata[:height]
66
- add_error(record, attribute, :aspect_ratio_not_square)
67
-
68
- when :portrait
69
- return true if metadata[:height] > metadata[:width]
70
- add_error(record, attribute, :aspect_ratio_not_portrait)
71
-
72
- when :landscape
73
- return true if metadata[:width] > metadata[:height]
74
- add_error(record, attribute, :aspect_ratio_not_landscape)
75
-
76
- else
77
- if options[:with] =~ /is\_(\d*)\_(\d*)/
78
- x = $1.to_i
79
- y = $2.to_i
80
-
81
- return true if (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
82
-
83
- add_error(record, attribute, :aspect_ratio_is_not, "#{x}x#{y}")
84
- else
85
- add_error(record, attribute, :aspect_ratio_unknown)
86
- end
87
- end
88
- false
89
- end
90
-
91
-
92
- def add_error(record, attribute, type, interpolate = options[:with])
93
- key = options[:message].presence || type
94
- return if record.errors.added?(attribute, key)
95
- record.errors.add(attribute, key, aspect_ratio: interpolate)
96
- end
97
-
98
- end
99
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_analyzable'
5
+ require_relative 'shared/asv_attachable'
6
+ require_relative 'shared/asv_errorable'
7
+ require_relative 'shared/asv_optionable'
8
+ require_relative 'shared/asv_symbolizable'
9
+
10
+ module ActiveStorageValidations
11
+ class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
12
+ include ASVActiveStorageable
13
+ include ASVAnalyzable
14
+ include ASVAttachable
15
+ include ASVErrorable
16
+ include ASVOptionable
17
+ include ASVSymbolizable
18
+
19
+ AVAILABLE_CHECKS = %i[with in].freeze
20
+ NAMED_ASPECT_RATIOS = %i[square portrait landscape].freeze
21
+ ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/.freeze
22
+ ERROR_TYPES = %i[
23
+ aspect_ratio_not_square
24
+ aspect_ratio_not_portrait
25
+ aspect_ratio_not_landscape
26
+ aspect_ratio_not_x_y
27
+ aspect_ratio_invalid
28
+ media_metadata_missing
29
+ ].freeze
30
+ PRECISION = 3.freeze
31
+ METADATA_KEYS = %i[width height].freeze
32
+
33
+ def check_validity!
34
+ ensure_at_least_one_validator_option
35
+ ensure_aspect_ratio_validity
36
+ end
37
+
38
+ def validate_each(record, attribute, _value)
39
+ return if no_attachments?(record, attribute)
40
+
41
+ flat_options = set_flat_options(record)
42
+ @authorized_aspect_ratios = authorized_aspect_ratios_from_options(flat_options).compact
43
+ return if @authorized_aspect_ratios.empty?
44
+
45
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
46
+ end
47
+
48
+ private
49
+
50
+ def is_valid?(record, attribute, attachable, metadata)
51
+ !media_metadata_missing?(record, attribute, attachable, metadata) &&
52
+ authorized_aspect_ratio?(record, attribute, attachable, metadata)
53
+ end
54
+
55
+ def authorized_aspect_ratio?(record, attribute, attachable, metadata)
56
+ attachable_aspect_ratio_is_authorized = @authorized_aspect_ratios.any? do |authorized_aspect_ratio|
57
+ case authorized_aspect_ratio
58
+ when :square then valid_square_aspect_ratio?(metadata)
59
+ when :portrait then valid_portrait_aspect_ratio?(metadata)
60
+ when :landscape then valid_landscape_aspect_ratio?(metadata)
61
+ when ASPECT_RATIO_REGEX then valid_regex_aspect_ratio?(authorized_aspect_ratio, metadata)
62
+ end
63
+ end
64
+
65
+ return true if attachable_aspect_ratio_is_authorized
66
+
67
+ errors_options = initialize_error_options(options, attachable)
68
+ error_type = aspect_ratio_error_mapping
69
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
70
+ errors_options[:width] = metadata[:width]
71
+ errors_options[:height] = metadata[:height]
72
+ add_error(record, attribute, error_type, **errors_options)
73
+ false
74
+ end
75
+
76
+ def aspect_ratio_error_mapping
77
+ return :aspect_ratio_invalid if @authorized_aspect_ratios.many?
78
+
79
+ aspect_ratio = @authorized_aspect_ratios.first
80
+ NAMED_ASPECT_RATIOS.include?(aspect_ratio) ? :"aspect_ratio_not_#{aspect_ratio}" : :aspect_ratio_not_x_y
81
+ end
82
+
83
+ def media_metadata_missing?(record, attribute, attachable, metadata)
84
+ return false if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
85
+
86
+ errors_options = initialize_error_options(options, attachable)
87
+ errors_options[:authorized_aspect_ratios] = string_aspect_ratios
88
+ add_error(record, attribute, :media_metadata_missing, **errors_options)
89
+ true
90
+ end
91
+
92
+ def valid_square_aspect_ratio?(metadata)
93
+ metadata[:width] == metadata[:height]
94
+ end
95
+
96
+ def valid_portrait_aspect_ratio?(metadata)
97
+ metadata[:width] < metadata[:height]
98
+ end
99
+
100
+ def valid_landscape_aspect_ratio?(metadata)
101
+ metadata[:width] > metadata[:height]
102
+ end
103
+
104
+ def valid_regex_aspect_ratio?(aspect_ratio, metadata)
105
+ aspect_ratio =~ ASPECT_RATIO_REGEX
106
+ x = ::Regexp.last_match(1).to_i
107
+ y = ::Regexp.last_match(2).to_i
108
+
109
+ x > 0 && y > 0 && (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
110
+ end
111
+
112
+ def ensure_at_least_one_validator_option
113
+ return if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
114
+
115
+ raise ArgumentError, 'You must pass either :with or :in to the validator'
116
+ end
117
+
118
+ def ensure_aspect_ratio_validity
119
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
120
+
121
+ authorized_aspect_ratios_from_options(options).each do |aspect_ratio|
122
+ unless NAMED_ASPECT_RATIOS.include?(aspect_ratio) || aspect_ratio =~ ASPECT_RATIO_REGEX
123
+ raise ArgumentError, invalid_aspect_ratio_message
124
+ end
125
+ end
126
+ end
127
+
128
+ def invalid_aspect_ratio_message
129
+ <<~ERROR_MESSAGE
130
+ You must pass a valid aspect ratio to the validator
131
+ It should either be a named aspect ratio (#{NAMED_ASPECT_RATIOS.join(', ')})
132
+ Or an aspect ratio like 'is_16_9' (matching /#{ASPECT_RATIO_REGEX.source}/)
133
+ ERROR_MESSAGE
134
+ end
135
+
136
+ def authorized_aspect_ratios_from_options(flat_options)
137
+ (Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in]))
138
+ end
139
+
140
+ def string_aspect_ratios
141
+ @authorized_aspect_ratios.map do |aspect_ratio|
142
+ if NAMED_ASPECT_RATIOS.include?(aspect_ratio)
143
+ aspect_ratio
144
+ else
145
+ aspect_ratio =~ ASPECT_RATIO_REGEX
146
+ x = ::Regexp.last_match(1).to_i
147
+ y = ::Regexp.last_match(2).to_i
148
+
149
+ "#{x}:#{y}"
150
+ end
151
+ end.join(', ')
152
+ end
153
+ end
154
+ end
@@ -1,14 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_errorable'
5
+ require_relative 'shared/asv_symbolizable'
6
+
3
7
  module ActiveStorageValidations
4
8
  class AttachedValidator < ActiveModel::EachValidator # :nodoc:
5
- def validate_each(record, attribute, _value)
6
- return if record.send(attribute).attached?
9
+ include ASVActiveStorageable
10
+ include ASVErrorable
11
+ include ASVSymbolizable
12
+
13
+ ERROR_TYPES = %i[blank].freeze
7
14
 
8
- errors_options = {}
9
- errors_options[:message] = options[:message] if options[:message].present?
15
+ def check_validity!
16
+ %i[allow_nil allow_blank].each do |not_authorized_option|
17
+ if options.include?(not_authorized_option)
18
+ raise ArgumentError, "You cannot pass the :#{not_authorized_option} option to the #{self.class.to_sym} validator"
19
+ end
20
+ end
21
+ end
22
+
23
+ def validate_each(record, attribute, _value)
24
+ return if attachments_present?(record, attribute) &&
25
+ will_have_attachments_after_save?(record, attribute)
10
26
 
11
- record.errors.add(attribute, :blank, **errors_options)
27
+ errors_options = initialize_error_options(options)
28
+ add_error(record, attribute, ERROR_TYPES.first, **errors_options)
12
29
  end
13
30
  end
14
31
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_errorable'
5
+ require_relative 'shared/asv_optionable'
6
+ require_relative 'shared/asv_symbolizable'
7
+
8
+ module ActiveStorageValidations
9
+ class BaseComparisonValidator < ActiveModel::EachValidator # :nodoc:
10
+ include ASVActiveStorageable
11
+ include ASVErrorable
12
+ include ASVOptionable
13
+ include ASVSymbolizable
14
+
15
+ AVAILABLE_CHECKS = %i[
16
+ less_than
17
+ less_than_or_equal_to
18
+ greater_than
19
+ greater_than_or_equal_to
20
+ between
21
+ ].freeze
22
+
23
+ def initialize(*args)
24
+ if self.class == BaseComparisonValidator
25
+ raise NotImplementedError, 'BaseComparisonValidator is an abstract class and cannot be instantiated directly.'
26
+ end
27
+ super
28
+ end
29
+
30
+ def check_validity!
31
+ unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
32
+ raise ArgumentError, 'You must pass either :less_than(_or_equal_to), :greater_than(_or_equal_to), or :between to the validator'
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def is_valid?(value, flat_options)
39
+ return false if value < 0
40
+
41
+ if flat_options[:between].present?
42
+ flat_options[:between].include?(value)
43
+ elsif flat_options[:less_than].present?
44
+ value < flat_options[:less_than]
45
+ elsif flat_options[:less_than_or_equal_to].present?
46
+ value <= flat_options[:less_than_or_equal_to]
47
+ elsif flat_options[:greater_than].present?
48
+ value > flat_options[:greater_than]
49
+ elsif flat_options[:greater_than_or_equal_to].present?
50
+ value >= flat_options[:greater_than_or_equal_to]
51
+ end
52
+ end
53
+
54
+ def populate_error_options(errors_options, flat_options)
55
+ errors_options[:min] = format_bound_value(min(flat_options))
56
+ errors_options[:max] = format_bound_value(max(flat_options))
57
+ end
58
+
59
+ def format_bound_value
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def min(flat_options)
64
+ flat_options[:between]&.min || flat_options[:greater_than] || flat_options[:greater_than_or_equal_to]
65
+ end
66
+
67
+ def max(flat_options)
68
+ flat_options[:between]&.max || flat_options[:less_than] || flat_options[:less_than_or_equal_to]
69
+ end
70
+ end
71
+ end