active_storage_validations 1.3.3 → 1.3.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +46 -0
- data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +57 -0
- data/lib/active_storage_validations/analyzer/image_analyzer.rb +77 -0
- data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
- data/lib/active_storage_validations/analyzer.rb +34 -0
- data/lib/active_storage_validations/aspect_ratio_validator.rb +3 -1
- data/lib/active_storage_validations/content_type_spoof_detector.rb +2 -0
- data/lib/active_storage_validations/content_type_validator.rb +25 -14
- data/lib/active_storage_validations/dimension_validator.rb +2 -0
- data/lib/active_storage_validations/matchers/processable_image_validator_matcher.rb +4 -4
- data/lib/active_storage_validations/matchers.rb +2 -1
- data/lib/active_storage_validations/processable_image_validator.rb +2 -0
- data/lib/active_storage_validations/shared/asv_analyzable.rb +45 -0
- data/lib/active_storage_validations/shared/asv_attachable.rb +63 -16
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations.rb +6 -0
- metadata +8 -3
- data/lib/active_storage_validations/metadata.rb +0 -179
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1fe705d1a3b76d875ddc0a61d7d3caa439ddad3ba4a8b5be3c203f8d8cdd00ca
|
4
|
+
data.tar.gz: 826dbac96af4e1ed424ad16670aea3e428296ab07ae9bfdad29e4c0b453d114d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4ccb7a673c9c5a7d6508e49c6b4b92b50b08faa9395a6e6c7da3bef460a6b72b64900d2ce7e529c5f18224c5b96e23bf82e8142f5d6dc047c327216b117bff21
|
7
|
+
data.tar.gz: f29bcdf4a6142b99d1cabf53086b682925256afc433e106a52feca3e96f097203c5e27ed7419ea51fc9b4ee6a2c454b06b0cdbb56a1d3f890fa6f452b529be4f
|
data/README.md
CHANGED
@@ -354,8 +354,8 @@ describe User do
|
|
354
354
|
|
355
355
|
# limit
|
356
356
|
# #min, #max
|
357
|
-
it { is_expected.to
|
358
|
-
it { is_expected.to
|
357
|
+
it { is_expected.to validate_limits_of(:avatar).min(1) }
|
358
|
+
it { is_expected.to validate_limits_of(:avatar).max(5) }
|
359
359
|
|
360
360
|
# content_type:
|
361
361
|
# #allowing, #rejecting
|
@@ -0,0 +1,46 @@
|
|
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_image
|
12
|
+
begin
|
13
|
+
require "mini_magick" unless defined?(MiniMagick)
|
14
|
+
rescue LoadError
|
15
|
+
logger.info "Skipping image analysis because the mini_magick gem isn't installed"
|
16
|
+
return {}
|
17
|
+
end
|
18
|
+
|
19
|
+
Tempfile.create(binmode: true) do |tempfile|
|
20
|
+
begin
|
21
|
+
if image(tempfile).valid?
|
22
|
+
yield image(tempfile)
|
23
|
+
else
|
24
|
+
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
|
25
|
+
{}
|
26
|
+
end
|
27
|
+
ensure
|
28
|
+
tempfile.close
|
29
|
+
end
|
30
|
+
end
|
31
|
+
rescue MiniMagick::Error => error
|
32
|
+
logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
|
33
|
+
{}
|
34
|
+
end
|
35
|
+
|
36
|
+
def image_from_path(path)
|
37
|
+
instrument("mini_magick") do
|
38
|
+
MiniMagick::Image.new(path)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def rotated_image?(image)
|
43
|
+
%w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
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_image
|
11
|
+
begin
|
12
|
+
require "vips" unless defined?(::Vips)
|
13
|
+
rescue LoadError
|
14
|
+
logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
|
15
|
+
return {}
|
16
|
+
end
|
17
|
+
|
18
|
+
Tempfile.create(binmode: true) do |tempfile|
|
19
|
+
begin
|
20
|
+
if image(tempfile)
|
21
|
+
yield image(tempfile)
|
22
|
+
else
|
23
|
+
logger.info "Skipping image analysis because Vips doesn't support the file"
|
24
|
+
{}
|
25
|
+
end
|
26
|
+
ensure
|
27
|
+
tempfile.close
|
28
|
+
end
|
29
|
+
end
|
30
|
+
rescue ::Vips::Error => error
|
31
|
+
logger.error "Skipping image analysis due to a Vips error: #{error.message}"
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
|
35
|
+
def image_from_path(path)
|
36
|
+
instrument("vips") do
|
37
|
+
begin
|
38
|
+
::Vips::Image.new_from_file(path, access: :sequential)
|
39
|
+
rescue ::Vips::Error
|
40
|
+
# Vips throw errors rather than returning false when reading a not
|
41
|
+
# supported attachable.
|
42
|
+
# We stumbled upon this issue while reading 0 byte size attachable
|
43
|
+
# https://github.com/janko/image_processing/issues/97
|
44
|
+
logger.info "Skipping image analysis because Vips doesn't support the file"
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
|
51
|
+
def rotated_image?(image)
|
52
|
+
ROTATIONS === image.get("exif-ifd0-Orientation")
|
53
|
+
rescue ::Vips::Error
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# = Active Storage 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
|
+
# ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
|
13
|
+
# # => { width: 4104, height: 2736 }
|
14
|
+
class Analyzer::ImageAnalyzer < Analyzer
|
15
|
+
def metadata
|
16
|
+
read_image do |image|
|
17
|
+
if rotated_image?(image)
|
18
|
+
{ width: image.height, height: image.width }
|
19
|
+
else
|
20
|
+
{ width: image.width, height: image.height }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def image(tempfile)
|
28
|
+
case @attachable
|
29
|
+
when ActiveStorage::Blob, String
|
30
|
+
blob = @attachable.is_a?(String) ? ActiveStorage::Blob.find_signed!(@attachable) : @attachable
|
31
|
+
image_from_tempfile_path(tempfile, blob)
|
32
|
+
when Hash
|
33
|
+
io = @attachable[:io]
|
34
|
+
if io.is_a?(StringIO)
|
35
|
+
image_from_tempfile_path(tempfile, io)
|
36
|
+
else
|
37
|
+
File.open(io) do |file|
|
38
|
+
image_from_path(file.path)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
42
|
+
image_from_path(@attachable.path)
|
43
|
+
when File
|
44
|
+
supports_file_attachment? ? image_from_path(@attachable.path) : raise_rails_like_error(@attachable)
|
45
|
+
when Pathname
|
46
|
+
supports_pathname_attachment? ? image_from_path(@attachable.to_s) : raise_rails_like_error(@attachable)
|
47
|
+
else
|
48
|
+
raise_rails_like_error(@attachable)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def image_from_tempfile_path(tempfile, file_representation)
|
53
|
+
if file_representation.is_a?(ActiveStorage::Blob)
|
54
|
+
file_representation.download { |chunk| tempfile.write(chunk) }
|
55
|
+
else
|
56
|
+
IO.copy_stream(file_representation, tempfile)
|
57
|
+
file_representation.rewind
|
58
|
+
end
|
59
|
+
|
60
|
+
tempfile.flush
|
61
|
+
tempfile.rewind
|
62
|
+
image_from_path(tempfile.path)
|
63
|
+
end
|
64
|
+
|
65
|
+
def read_image
|
66
|
+
raise NotImplementedError
|
67
|
+
end
|
68
|
+
|
69
|
+
def image_from_path(path)
|
70
|
+
raise NotImplementedError
|
71
|
+
end
|
72
|
+
|
73
|
+
def rotated_image?(image)
|
74
|
+
raise NotImplementedError
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# = Active Storage 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
|
+
# ActiveStorage::Analyzer::NullAnalyzer.new(attachable).metadata
|
12
|
+
# # => {}
|
13
|
+
class Analyzer::NullAnalyzer < Analyzer
|
14
|
+
def metadata
|
15
|
+
{}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,34 @@
|
|
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::ImageAnalyzer 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 Hash of metadata.
|
24
|
+
def metadata
|
25
|
+
raise NotImplementedError
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def instrument(analyzer, &block)
|
31
|
+
ActiveSupport::Notifications.instrument("analyze.active_storage_validations", analyzer: analyzer, &block)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative 'shared/asv_active_storageable'
|
4
|
+
require_relative 'shared/asv_analyzable'
|
4
5
|
require_relative 'shared/asv_attachable'
|
5
6
|
require_relative 'shared/asv_errorable'
|
6
7
|
require_relative 'shared/asv_optionable'
|
@@ -9,6 +10,7 @@ require_relative 'shared/asv_symbolizable'
|
|
9
10
|
module ActiveStorageValidations
|
10
11
|
class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
|
11
12
|
include ASVActiveStorageable
|
13
|
+
include ASVAnalyzable
|
12
14
|
include ASVAttachable
|
13
15
|
include ASVErrorable
|
14
16
|
include ASVOptionable
|
@@ -53,7 +55,7 @@ module ActiveStorageValidations
|
|
53
55
|
end
|
54
56
|
|
55
57
|
def image_metadata_missing?(record, attribute, attachable, flat_options, metadata)
|
56
|
-
return false if metadata[:width].to_i > 0 && metadata[:height].to_i > 0
|
58
|
+
return false if metadata.present? && metadata[:width].to_i > 0 && metadata[:height].to_i > 0
|
57
59
|
|
58
60
|
errors_options = initialize_error_options(options, attachable)
|
59
61
|
errors_options[:aspect_ratio] = flat_options[:with]
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'shared/asv_analyzable'
|
3
4
|
require_relative 'shared/asv_attachable'
|
4
5
|
require_relative 'shared/asv_loggable'
|
5
6
|
require 'open3'
|
@@ -8,6 +9,7 @@ module ActiveStorageValidations
|
|
8
9
|
class ContentTypeSpoofDetector
|
9
10
|
class FileCommandLineToolNotInstalledError < StandardError; end
|
10
11
|
|
12
|
+
include ASVAnalyzable
|
11
13
|
include ASVAttachable
|
12
14
|
include ASVLoggable
|
13
15
|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative 'shared/asv_active_storageable'
|
4
|
+
require_relative 'shared/asv_analyzable'
|
4
5
|
require_relative 'shared/asv_attachable'
|
5
6
|
require_relative 'shared/asv_errorable'
|
6
7
|
require_relative 'shared/asv_optionable'
|
@@ -10,6 +11,7 @@ require_relative 'content_type_spoof_detector'
|
|
10
11
|
module ActiveStorageValidations
|
11
12
|
class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
|
12
13
|
include ASVActiveStorageable
|
14
|
+
include ASVAnalyzable
|
13
15
|
include ASVAttachable
|
14
16
|
include ASVErrorable
|
15
17
|
include ASVOptionable
|
@@ -52,27 +54,36 @@ module ActiveStorageValidations
|
|
52
54
|
end
|
53
55
|
|
54
56
|
def set_attachable_cached_values(attachable)
|
55
|
-
@attachable_content_type =
|
57
|
+
@attachable_content_type = attachable_content_type_rails_like(attachable)
|
56
58
|
@attachable_filename = attachable_filename(attachable).to_s
|
57
59
|
end
|
58
60
|
|
61
|
+
# Check if the provided content_type is authorized and not spoofed against
|
62
|
+
# the file io.
|
59
63
|
def is_valid?(record, attribute, attachable)
|
60
|
-
|
61
|
-
authorized_content_type?(record, attribute, attachable) &&
|
64
|
+
authorized_content_type?(record, attribute, attachable) &&
|
62
65
|
not_spoofing_content_type?(record, attribute, attachable)
|
63
66
|
end
|
64
67
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
68
|
+
# Dead code that we keep here for some time, maybe we will find a solution
|
69
|
+
# to this check later? (November 2024)
|
70
|
+
#
|
71
|
+
# We do not perform any validations against the extension because it is an
|
72
|
+
# unreliable source of truth. For example, a `.csv` file could have its
|
73
|
+
# `text/csv` content_type changed to `application/vnd.ms-excel` because
|
74
|
+
# it had been opened by Excel at some point, making the file extension vs
|
75
|
+
# file content_type check invalid.
|
76
|
+
# def extension_matches_content_type?(record, attribute, attachable)
|
77
|
+
# return true if !@attachable_filename || !@attachable_content_type
|
78
|
+
|
79
|
+
# extension = @attachable_filename.split('.').last
|
80
|
+
# possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
|
81
|
+
# return true if possible_extensions && extension.downcase.in?(possible_extensions)
|
82
|
+
|
83
|
+
# errors_options = initialize_and_populate_error_options(options, attachable)
|
84
|
+
# add_error(record, attribute, ERROR_TYPES.first, **errors_options)
|
85
|
+
# false
|
86
|
+
# end
|
76
87
|
|
77
88
|
def authorized_content_type?(record, attribute, attachable)
|
78
89
|
attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative 'shared/asv_active_storageable'
|
4
|
+
require_relative 'shared/asv_analyzable'
|
4
5
|
require_relative 'shared/asv_attachable'
|
5
6
|
require_relative 'shared/asv_errorable'
|
6
7
|
require_relative 'shared/asv_optionable'
|
@@ -9,6 +10,7 @@ require_relative 'shared/asv_symbolizable'
|
|
9
10
|
module ActiveStorageValidations
|
10
11
|
class DimensionValidator < ActiveModel::EachValidator # :nodoc
|
11
12
|
include ASVActiveStorageable
|
13
|
+
include ASVAnalyzable
|
12
14
|
include ASVAttachable
|
13
15
|
include ASVErrorable
|
14
16
|
include ASVOptionable
|
@@ -43,10 +43,10 @@ module ActiveStorageValidations
|
|
43
43
|
@subject = subject.is_a?(Class) ? subject.new : subject
|
44
44
|
|
45
45
|
is_a_valid_active_storage_attribute? &&
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
46
|
+
is_context_valid? &&
|
47
|
+
is_custom_message_valid? &&
|
48
|
+
is_valid_when_image_processable? &&
|
49
|
+
is_invalid_when_image_not_processable?
|
50
50
|
end
|
51
51
|
|
52
52
|
private
|
@@ -27,7 +27,8 @@ module ActiveStorageValidations
|
|
27
27
|
|
28
28
|
def self.mock_metadata(attachment, width, height)
|
29
29
|
mock = Struct.new(:metadata).new({ width: width, height: height })
|
30
|
-
|
30
|
+
|
31
|
+
stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
|
31
32
|
yield
|
32
33
|
end
|
33
34
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative 'shared/asv_active_storageable'
|
4
|
+
require_relative 'shared/asv_analyzable'
|
4
5
|
require_relative 'shared/asv_attachable'
|
5
6
|
require_relative 'shared/asv_errorable'
|
6
7
|
require_relative 'shared/asv_symbolizable'
|
@@ -8,6 +9,7 @@ require_relative 'shared/asv_symbolizable'
|
|
8
9
|
module ActiveStorageValidations
|
9
10
|
class ProcessableImageValidator < ActiveModel::EachValidator # :nodoc
|
10
11
|
include ASVActiveStorageable
|
12
|
+
include ASVAnalyzable
|
11
13
|
include ASVAttachable
|
12
14
|
include ASVErrorable
|
13
15
|
include ASVSymbolizable
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# ActiveStorageValidations::ASVAnalyzable
|
5
|
+
#
|
6
|
+
# Validator methods for choosing the right analyzer depending on the file
|
7
|
+
# media type and available third-party analyzers.
|
8
|
+
module ASVAnalyzable
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
DEFAULT_IMAGE_PROCESSOR = :mini_magick.freeze
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def metadata_for(attachable)
|
16
|
+
analyzer_for(attachable).metadata
|
17
|
+
end
|
18
|
+
|
19
|
+
def analyzer_for(attachable)
|
20
|
+
case attachable_media_type(attachable)
|
21
|
+
when "image" then image_analyzer_for(attachable)
|
22
|
+
else fallback_analyzer_for(attachable)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def image_analyzer_for(attachable)
|
27
|
+
case image_processor
|
28
|
+
when :mini_magick
|
29
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
|
30
|
+
when :vips
|
31
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def image_processor
|
36
|
+
# Rails returns nil for default image processor, because it is set in an after initialize callback
|
37
|
+
# https://github.com/rails/rails/blob/main/activestorage/lib/active_storage/engine.rb
|
38
|
+
ActiveStorage.variant_processor || DEFAULT_IMAGE_PROCESSOR
|
39
|
+
end
|
40
|
+
|
41
|
+
def fallback_analyzer_for(attachable)
|
42
|
+
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "../metadata"
|
4
|
-
|
5
3
|
module ActiveStorageValidations
|
6
4
|
# ActiveStorageValidations::ASVAttachable
|
7
5
|
#
|
@@ -20,7 +18,7 @@ module ActiveStorageValidations
|
|
20
18
|
# to perform file analyses.
|
21
19
|
def validate_changed_files_from_metadata(record, attribute)
|
22
20
|
attachables_from_changes(record, attribute).each do |attachable|
|
23
|
-
is_valid?(record, attribute, attachable,
|
21
|
+
is_valid?(record, attribute, attachable, metadata_for(attachable))
|
24
22
|
end
|
25
23
|
end
|
26
24
|
|
@@ -64,25 +62,67 @@ module ActiveStorageValidations
|
|
64
62
|
def attachable_content_type(attachable)
|
65
63
|
full_attachable_content_type(attachable) && full_attachable_content_type(attachable).downcase.split(/[;,\s]/, 2).first
|
66
64
|
end
|
65
|
+
|
66
|
+
# Retrieve the content_type from attachable using the same logic as Rails
|
67
|
+
# ActiveStorage::Blob::Identifiable#identify_content_type
|
68
|
+
def attachable_content_type_rails_like(attachable)
|
69
|
+
Marcel::MimeType.for(
|
70
|
+
attachable_io(attachable, max_byte_size: 4.kilobytes),
|
71
|
+
name: attachable_filename(attachable).to_s,
|
72
|
+
declared_type: full_attachable_content_type(attachable)
|
73
|
+
)
|
74
|
+
end
|
67
75
|
|
76
|
+
# Retrieve the media type of the attachable, which is the first part of the
|
77
|
+
# content type (or mime type).
|
78
|
+
# Possible values are: application/audio/example/font/image/model/text/video
|
79
|
+
def attachable_media_type(attachable)
|
80
|
+
(full_attachable_content_type(attachable) || marcel_content_type_from_filename(attachable)).split("/").first
|
81
|
+
end
|
82
|
+
|
68
83
|
# Retrieve the io from attachable.
|
69
|
-
def attachable_io(attachable)
|
84
|
+
def attachable_io(attachable, max_byte_size: nil)
|
85
|
+
io = case attachable
|
86
|
+
when ActiveStorage::Blob
|
87
|
+
(max_byte_size && supports_blob_download_chunk?) ? attachable.download_chunk(0...max_byte_size) : attachable.download
|
88
|
+
when ActionDispatch::Http::UploadedFile
|
89
|
+
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
90
|
+
when Rack::Test::UploadedFile
|
91
|
+
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
92
|
+
when String
|
93
|
+
blob = ActiveStorage::Blob.find_signed!(attachable)
|
94
|
+
(max_byte_size && supports_blob_download_chunk?) ? blob.download_chunk(0...max_byte_size) : blob.download
|
95
|
+
when Hash
|
96
|
+
max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
|
97
|
+
when File
|
98
|
+
raise_rails_like_error(attachable) unless supports_file_attachment?
|
99
|
+
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
100
|
+
when Pathname
|
101
|
+
raise_rails_like_error(attachable) unless supports_pathname_attachment?
|
102
|
+
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
103
|
+
else
|
104
|
+
raise_rails_like_error(attachable)
|
105
|
+
end
|
106
|
+
|
107
|
+
rewind_attachable_io(attachable)
|
108
|
+
io
|
109
|
+
end
|
110
|
+
|
111
|
+
# Rewind the io attachable.
|
112
|
+
def rewind_attachable_io(attachable)
|
70
113
|
case attachable
|
71
|
-
when ActiveStorage::Blob
|
72
|
-
|
73
|
-
when ActionDispatch::Http::UploadedFile
|
74
|
-
attachable.
|
75
|
-
when Rack::Test::UploadedFile
|
76
|
-
attachable.read
|
77
|
-
when String
|
78
|
-
blob = ActiveStorage::Blob.find_signed!(attachable)
|
79
|
-
blob.download
|
114
|
+
when ActiveStorage::Blob, String
|
115
|
+
# nothing to do
|
116
|
+
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
117
|
+
attachable.rewind
|
80
118
|
when Hash
|
81
|
-
attachable[:io].
|
119
|
+
attachable[:io].rewind
|
82
120
|
when File
|
83
|
-
|
121
|
+
raise_rails_like_error(attachable) unless supports_file_attachment?
|
122
|
+
attachable.rewind
|
84
123
|
when Pathname
|
85
|
-
|
124
|
+
raise_rails_like_error(attachable) unless supports_pathname_attachment?
|
125
|
+
File.open(attachable) { |f| f.rewind }
|
86
126
|
else
|
87
127
|
raise_rails_like_error(attachable)
|
88
128
|
end
|
@@ -128,6 +168,13 @@ module ActiveStorageValidations
|
|
128
168
|
end
|
129
169
|
alias :supports_pathname_attachment? :supports_file_attachment?
|
130
170
|
|
171
|
+
# Check if the current Rails version supports ActiveStorage::Blob#download_chunk
|
172
|
+
#
|
173
|
+
# https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
|
174
|
+
def supports_blob_download_chunk?
|
175
|
+
Rails.gem_version >= Gem::Version.new('7.0.0.alpha1')
|
176
|
+
end
|
177
|
+
|
131
178
|
# Retrieve the content_type from the file name only
|
132
179
|
def marcel_content_type_from_filename(attachable)
|
133
180
|
Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
|
@@ -3,6 +3,12 @@
|
|
3
3
|
require 'active_model'
|
4
4
|
require 'active_support/concern'
|
5
5
|
|
6
|
+
require 'active_storage_validations/analyzer'
|
7
|
+
require 'active_storage_validations/analyzer/image_analyzer'
|
8
|
+
require 'active_storage_validations/analyzer/image_analyzer/image_magick'
|
9
|
+
require 'active_storage_validations/analyzer/image_analyzer/vips'
|
10
|
+
require 'active_storage_validations/analyzer/null_analyzer'
|
11
|
+
|
6
12
|
require 'active_storage_validations/railtie'
|
7
13
|
require 'active_storage_validations/engine'
|
8
14
|
require 'active_storage_validations/attached_validator'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_storage_validations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.3.
|
4
|
+
version: 1.3.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Igor Kasyanchuk
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-11
|
11
|
+
date: 2024-12-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -259,6 +259,11 @@ files:
|
|
259
259
|
- config/locales/vi.yml
|
260
260
|
- config/locales/zh-CN.yml
|
261
261
|
- lib/active_storage_validations.rb
|
262
|
+
- lib/active_storage_validations/analyzer.rb
|
263
|
+
- lib/active_storage_validations/analyzer/image_analyzer.rb
|
264
|
+
- lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb
|
265
|
+
- lib/active_storage_validations/analyzer/image_analyzer/vips.rb
|
266
|
+
- lib/active_storage_validations/analyzer/null_analyzer.rb
|
262
267
|
- lib/active_storage_validations/aspect_ratio_validator.rb
|
263
268
|
- lib/active_storage_validations/attached_validator.rb
|
264
269
|
- lib/active_storage_validations/base_size_validator.rb
|
@@ -285,10 +290,10 @@ files:
|
|
285
290
|
- lib/active_storage_validations/matchers/shared/asv_validatable.rb
|
286
291
|
- lib/active_storage_validations/matchers/size_validator_matcher.rb
|
287
292
|
- lib/active_storage_validations/matchers/total_size_validator_matcher.rb
|
288
|
-
- lib/active_storage_validations/metadata.rb
|
289
293
|
- lib/active_storage_validations/processable_image_validator.rb
|
290
294
|
- lib/active_storage_validations/railtie.rb
|
291
295
|
- lib/active_storage_validations/shared/asv_active_storageable.rb
|
296
|
+
- lib/active_storage_validations/shared/asv_analyzable.rb
|
292
297
|
- lib/active_storage_validations/shared/asv_attachable.rb
|
293
298
|
- lib/active_storage_validations/shared/asv_errorable.rb
|
294
299
|
- lib/active_storage_validations/shared/asv_loggable.rb
|
@@ -1,179 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'shared/asv_loggable'
|
4
|
-
|
5
|
-
module ActiveStorageValidations
|
6
|
-
class Metadata
|
7
|
-
include ASVLoggable
|
8
|
-
|
9
|
-
class InvalidImageError < StandardError; end
|
10
|
-
|
11
|
-
attr_reader :attachable
|
12
|
-
|
13
|
-
DEFAULT_IMAGE_PROCESSOR = :mini_magick.freeze
|
14
|
-
|
15
|
-
def initialize(attachable)
|
16
|
-
require_image_processor
|
17
|
-
@attachable = attachable
|
18
|
-
end
|
19
|
-
|
20
|
-
def valid?
|
21
|
-
read_image
|
22
|
-
true
|
23
|
-
rescue InvalidImageError
|
24
|
-
false
|
25
|
-
end
|
26
|
-
|
27
|
-
def metadata
|
28
|
-
read_image do |image|
|
29
|
-
if rotated_image?(image)
|
30
|
-
{ width: image.height, height: image.width }
|
31
|
-
else
|
32
|
-
{ width: image.width, height: image.height }
|
33
|
-
end
|
34
|
-
end
|
35
|
-
rescue InvalidImageError
|
36
|
-
logger.info "Skipping image analysis because ImageMagick or Vips doesn't support the file"
|
37
|
-
{}
|
38
|
-
end
|
39
|
-
|
40
|
-
private
|
41
|
-
|
42
|
-
def image_processor
|
43
|
-
# Rails returns nil for default image processor, because it is set in an after initialize callback
|
44
|
-
# https://github.com/rails/rails/blob/89d8569abe2564c8187debf32dd3b4e33d6ad983/activestorage/lib/active_storage/engine.rb
|
45
|
-
Rails.application.config.active_storage.variant_processor || DEFAULT_IMAGE_PROCESSOR
|
46
|
-
end
|
47
|
-
|
48
|
-
def require_image_processor
|
49
|
-
case image_processor
|
50
|
-
when :vips then require 'vips' unless defined?(Vips)
|
51
|
-
when :mini_magick then require 'mini_magick' unless defined?(MiniMagick)
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
def exception_class
|
56
|
-
case image_processor
|
57
|
-
when :vips then Vips::Error
|
58
|
-
when :mini_magick then MiniMagick::Error
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
def vips_image_processor?
|
63
|
-
image_processor == :vips
|
64
|
-
end
|
65
|
-
|
66
|
-
def read_image
|
67
|
-
is_string = attachable.is_a?(String)
|
68
|
-
if is_string || attachable.is_a?(ActiveStorage::Blob)
|
69
|
-
blob = is_string ? ActiveStorage::Blob.find_signed!(attachable) : attachable
|
70
|
-
|
71
|
-
tempfile = Tempfile.new(["ActiveStorage-#{blob.id}-", blob.filename.extension_with_delimiter])
|
72
|
-
tempfile.binmode
|
73
|
-
|
74
|
-
blob.download do |chunk|
|
75
|
-
tempfile.write(chunk)
|
76
|
-
end
|
77
|
-
|
78
|
-
tempfile.flush
|
79
|
-
tempfile.rewind
|
80
|
-
|
81
|
-
image = new_image_from_path(tempfile.path)
|
82
|
-
else
|
83
|
-
file_path = read_file_path
|
84
|
-
image = new_image_from_path(file_path)
|
85
|
-
end
|
86
|
-
|
87
|
-
|
88
|
-
raise InvalidImageError unless valid_image?(image)
|
89
|
-
yield image if block_given?
|
90
|
-
rescue LoadError, NameError
|
91
|
-
logger.info "Skipping image analysis because the mini_magick or ruby-vips gem isn't installed"
|
92
|
-
{}
|
93
|
-
rescue exception_class => error
|
94
|
-
logger.error "Skipping image analysis due to an #{exception_class.name.split('::').map(&:downcase).join(' ').capitalize} error: #{error.message}"
|
95
|
-
{}
|
96
|
-
ensure
|
97
|
-
image = nil
|
98
|
-
end
|
99
|
-
|
100
|
-
def new_image_from_path(path)
|
101
|
-
if vips_image_processor? && (supported_vips_suffix?(path) || vips_version_below_8_8? || open_uri_tempfile?(path))
|
102
|
-
begin
|
103
|
-
Vips::Image.new_from_file(path)
|
104
|
-
rescue exception_class
|
105
|
-
# We handle cases where an error is raised when reading the attachable
|
106
|
-
# because Vips can throw errors rather than returning false
|
107
|
-
# We stumble upon this issue while reading 0 byte size attachable
|
108
|
-
# https://github.com/janko/image_processing/issues/97
|
109
|
-
false
|
110
|
-
end
|
111
|
-
elsif defined?(MiniMagick)
|
112
|
-
MiniMagick::Image.new(path)
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
def supported_vips_suffix?(path)
|
117
|
-
Vips::get_suffixes.include?(File.extname(path).downcase)
|
118
|
-
end
|
119
|
-
|
120
|
-
def vips_version_below_8_8?
|
121
|
-
# FYI, Vips 8.8 was released in 2019
|
122
|
-
# https://github.com/libvips/libvips/releases/tag/v8.8.0
|
123
|
-
!Vips::respond_to?(:vips_foreign_get_suffixes)
|
124
|
-
end
|
125
|
-
|
126
|
-
def open_uri_tempfile?(path)
|
127
|
-
# When trying to open urls for 'large' images, OpenURI will return a
|
128
|
-
# tempfile. That tempfile does not have an extension indicating the type
|
129
|
-
# of file. However, Vips will be able to process it anyway.
|
130
|
-
# The 'large' file value is derived from OpenUri::Buffer class (> 10ko)
|
131
|
-
path.split('/').last.starts_with?("open-uri")
|
132
|
-
end
|
133
|
-
|
134
|
-
def valid_image?(image)
|
135
|
-
return false unless image
|
136
|
-
|
137
|
-
vips_image_processor? && image.is_a?(Vips::Image) ? image.avg : image.valid?
|
138
|
-
rescue exception_class
|
139
|
-
false
|
140
|
-
end
|
141
|
-
|
142
|
-
def rotated_image?(image)
|
143
|
-
if vips_image_processor? && image.is_a?(Vips::Image)
|
144
|
-
image.get('exif-ifd0-Orientation').include?('Right-top') ||
|
145
|
-
image.get('exif-ifd0-Orientation').include?('Left-bottom')
|
146
|
-
else
|
147
|
-
%w[ RightTop LeftBottom ].include?(image["%[orientation]"])
|
148
|
-
end
|
149
|
-
rescue exception_class # field "exif-ifd0-Orientation" not found
|
150
|
-
false
|
151
|
-
end
|
152
|
-
|
153
|
-
def read_file_path
|
154
|
-
case attachable
|
155
|
-
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
156
|
-
attachable.path
|
157
|
-
when Hash
|
158
|
-
io = attachable.fetch(:io)
|
159
|
-
if io.is_a?(StringIO)
|
160
|
-
tempfile = Tempfile.new([File.basename(attachable[:filename], '.*'), File.extname(attachable[:filename])])
|
161
|
-
tempfile.binmode
|
162
|
-
IO.copy_stream(io, tempfile)
|
163
|
-
io.rewind
|
164
|
-
tempfile.flush
|
165
|
-
tempfile.rewind
|
166
|
-
tempfile.path
|
167
|
-
else
|
168
|
-
File.open(io).path
|
169
|
-
end
|
170
|
-
when File
|
171
|
-
attachable.path
|
172
|
-
when Pathname
|
173
|
-
attachable.to_s
|
174
|
-
else
|
175
|
-
raise "Something wrong with params."
|
176
|
-
end
|
177
|
-
end
|
178
|
-
end
|
179
|
-
end
|