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
@@ -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