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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ module ASVRspecable
8
+ extend ActiveSupport::Concern
9
+
10
+ def initialize_rspecable
11
+ @failure_message_artefacts = []
12
+ end
13
+
14
+ def description
15
+ raise NotImplementedError, "#{self.class} did not define #{__method__}"
16
+ end
17
+
18
+ def failure_message
19
+ raise NotImplementedError, "#{self.class} did not define #{__method__}"
20
+ end
21
+
22
+ def failure_message_when_negated
23
+ failure_message.sub(/is expected to validate/, 'is expected not to validate')
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ module ASVValidatable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ def validate
13
+ @subject.validate(@context)
14
+ end
15
+
16
+ def validator_errors_for_attribute
17
+ @subject.errors.details[@attribute_name].select do |error|
18
+ error[:validator_type] == validator_class.to_sym
19
+ end
20
+ end
21
+
22
+ def is_valid?
23
+ validator_errors_for_attribute.none? do |error|
24
+ error[:error].in?(available_errors)
25
+ end
26
+ end
27
+
28
+ def available_errors
29
+ [
30
+ *validator_class::ERROR_TYPES,
31
+ *errors_from_custom_messages
32
+ ].compact
33
+ end
34
+
35
+ def validator_class
36
+ self.class.name.gsub(/::Matchers|Matcher/, '').constantize
37
+ end
38
+
39
+ def attribute_validator
40
+ @subject.class.validators_on(@attribute_name).find do |validator|
41
+ validator.class == validator_class
42
+ end
43
+ end
44
+
45
+ def attribute_validators
46
+ @subject.class.validators_on(@attribute_name).select do |validator|
47
+ validator.class == validator_class
48
+ end
49
+ end
50
+
51
+ def errors_from_custom_messages
52
+ attribute_validators.map { |validator| validator.options[:message] }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,91 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Big thank you to the paperclip validation matchers:
4
- # https://github.com/thoughtbot/paperclip/blob/v6.1.0/lib/paperclip/matchers/validate_attachment_size_matcher.rb
3
+ require_relative 'base_comparison_validator_matcher'
4
+
5
5
  module ActiveStorageValidations
6
6
  module Matchers
7
- def validate_size_of(name)
8
- SizeValidatorMatcher.new(name)
7
+ def validate_size_of(attribute_name)
8
+ SizeValidatorMatcher.new(attribute_name)
9
9
  end
10
10
 
11
- class SizeValidatorMatcher
12
- def initialize(attribute_name)
13
- @attribute_name = attribute_name
14
- @low = @high = nil
15
- end
16
-
11
+ class SizeValidatorMatcher < BaseComparisonValidatorMatcher
17
12
  def description
18
- "validate file size of #{@attribute_name}"
19
- end
20
-
21
- def less_than(size)
22
- @high = size - 1.byte
23
- self
24
- end
25
-
26
- def less_than_or_equal_to(size)
27
- @high = size
28
- self
29
- end
30
-
31
- def greater_than(size)
32
- @low = size + 1.byte
33
- self
34
- end
35
-
36
- def greater_than_or_equal_to(size)
37
- @low = size
38
- self
39
- end
40
-
41
- def between(range)
42
- @low, @high = range.first, range.last
43
- self
44
- end
45
-
46
- def matches?(subject)
47
- @subject = subject.is_a?(Class) ? subject.new : subject
48
- responds_to_methods && lower_than_low? && higher_than_low? && lower_than_high? && higher_than_high?
13
+ "validate file size of :#{@attribute_name}"
49
14
  end
50
15
 
51
16
  def failure_message
52
- "is expected to validate file size of #{@attribute_name} to be between #{@low} and #{@high} bytes"
17
+ message = ["is expected to validate file size of :#{@attribute_name}"]
18
+ build_failure_message(message)
19
+ message.join("\n")
53
20
  end
54
21
 
55
- def failure_message_when_negated
56
- "is expected to not validate file size of #{@attribute_name} to be between #{@low} and #{@high} bytes"
57
- end
58
-
59
- protected
60
-
61
- def responds_to_methods
62
- @subject.respond_to?(@attribute_name) &&
63
- @subject.public_send(@attribute_name).respond_to?(:attach) &&
64
- @subject.public_send(@attribute_name).respond_to?(:detach)
65
- end
66
-
67
- def lower_than_low?
68
- @low.nil? || !passes_validation_with_size(@low - 1)
69
- end
70
-
71
- def higher_than_low?
72
- @low.nil? || passes_validation_with_size(@low + 1)
73
- end
22
+ private
74
23
 
75
- def lower_than_high?
76
- @high.nil? || @high == Float::INFINITY || passes_validation_with_size(@high - 1)
24
+ def failure_message_unit
25
+ "bytes"
77
26
  end
78
27
 
79
- def higher_than_high?
80
- @high.nil? || @high == Float::INFINITY || !passes_validation_with_size(@high + 1)
28
+ def smallest_measurement
29
+ 1.byte
81
30
  end
82
31
 
83
- def passes_validation_with_size(new_size)
84
- io = Tempfile.new('Hello world!')
85
- Matchers.stub_method(io, :size, new_size) do
86
- @subject.public_send(@attribute_name).attach(io: io, filename: 'test.png', content_type: 'image/pg')
87
- @subject.validate
88
- @subject.errors.details[@attribute_name].all? { |error| error[:error] != :file_size_out_of_range }
32
+ def mock_value_for(io, size)
33
+ Matchers.stub_method(io, :size, size) do
34
+ yield
89
35
  end
90
36
  end
91
37
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_comparison_validator_matcher'
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ def validate_total_size_of(attribute_name)
8
+ TotalSizeValidatorMatcher.new(attribute_name)
9
+ end
10
+
11
+ class TotalSizeValidatorMatcher < BaseComparisonValidatorMatcher
12
+ def description
13
+ "validate total file size of :#{@attribute_name}"
14
+ end
15
+
16
+ def failure_message
17
+ message = ["is expected to validate total file size of :#{@attribute_name}"]
18
+ build_failure_message(message)
19
+ message.join("\n")
20
+ end
21
+
22
+ protected
23
+
24
+ def attach_file
25
+ # has_many_attached relation
26
+ @subject.public_send(@attribute_name).attach([dummy_blob])
27
+ @subject.public_send(@attribute_name)
28
+ end
29
+
30
+ private
31
+
32
+ def failure_message_unit
33
+ "bytes"
34
+ end
35
+
36
+ def smallest_measurement
37
+ 1.byte
38
+ end
39
+
40
+ def mock_value_for(io, size)
41
+ Matchers.stub_method(io, :size, size) do
42
+ yield
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_storage_validations/matchers/aspect_ratio_validator_matcher'
3
4
  require 'active_storage_validations/matchers/attached_validator_matcher'
5
+ require 'active_storage_validations/matchers/processable_file_validator_matcher'
6
+ require 'active_storage_validations/matchers/limit_validator_matcher'
4
7
  require 'active_storage_validations/matchers/content_type_validator_matcher'
5
8
  require 'active_storage_validations/matchers/dimension_validator_matcher'
9
+ require 'active_storage_validations/matchers/duration_validator_matcher'
6
10
  require 'active_storage_validations/matchers/size_validator_matcher'
11
+ require 'active_storage_validations/matchers/total_size_validator_matcher'
7
12
 
8
13
  module ActiveStorageValidations
9
14
  module Matchers
@@ -21,22 +26,12 @@ module ActiveStorageValidations
21
26
  end
22
27
  end
23
28
 
24
- def self.mock_metadata(attachment, width, height)
25
- if Rails.gem_version >= Gem::Version.new('6.0.0')
26
- # Mock the Metadata class for rails 6
27
- mock = OpenStruct.new(metadata: { width: width, height: height })
28
- stub_method(ActiveStorageValidations::Metadata, :new, mock) do
29
- yield
30
- end
31
- else
32
- # Stub the metadata analysis for rails 5
33
- stub_method(attachment, :analyze, true) do
34
- stub_method(attachment, :analyzed?, true) do
35
- stub_method(attachment, :metadata, { width: width, height: height }) do
36
- yield
37
- end
38
- end
39
- end
29
+ def self.mock_metadata(attachment, metadata = {})
30
+ asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
31
+ mock = Struct.new(:metadata).new(asv_metadata_available_keys.merge(metadata)) # ensure all keys are present, and it does not raise while trying to access them
32
+
33
+ stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
34
+ yield
40
35
  end
41
36
  end
42
37
  end
@@ -0,0 +1,37 @@
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_symbolizable'
8
+
9
+ module ActiveStorageValidations
10
+ class ProcessableFileValidator < ActiveModel::EachValidator # :nodoc
11
+ include ASVActiveStorageable
12
+ include ASVAnalyzable
13
+ include ASVAttachable
14
+ include ASVErrorable
15
+ include ASVSymbolizable
16
+
17
+ ERROR_TYPES = %i[
18
+ file_not_processable
19
+ ].freeze
20
+ METADATA_KEYS = %i[].freeze
21
+
22
+ def validate_each(record, attribute, _value)
23
+ return if no_attachments?(record, attribute)
24
+
25
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
26
+ end
27
+
28
+ private
29
+
30
+ def is_valid?(record, attribute, attachable, metadata)
31
+ return if !metadata.empty?
32
+
33
+ errors_options = initialize_error_options(options, attachable)
34
+ add_error(record, attribute, ERROR_TYPES.first , **errors_options)
35
+ end
36
+ end
37
+ end
@@ -2,5 +2,16 @@
2
2
 
3
3
  module ActiveStorageValidations
4
4
  class Railtie < ::Rails::Railtie
5
+ initializer 'active_storage_validations.configure', after: :load_config_initializers do
6
+ ActiveSupport.on_load(:active_record) do
7
+ send :include, ActiveStorageValidations
8
+ end
9
+ end
10
+
11
+ initializer 'active_storage_validations.extend_active_storage_blob' do
12
+ ActiveSupport.on_load(:active_storage_blob) do
13
+ include(ActiveStorageValidations::ASVBlobMetadatable)
14
+ end
15
+ end
5
16
  end
6
17
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::ASVActiveStorageable
5
+ #
6
+ # Validator helper methods to make our code more explicit.
7
+ module ASVActiveStorageable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # Retrieve either an `ActiveStorage::Attached::One` or an
13
+ # `ActiveStorage::Attached::Many` instance depending on attribute definition
14
+ def attached_files(record, attribute)
15
+ Array.wrap(record.send(attribute))
16
+ end
17
+
18
+ def attachments_present?(record, attribute)
19
+ record.send(attribute).attached?
20
+ end
21
+
22
+ def no_attachments?(record, attribute)
23
+ !attachments_present?(record, attribute)
24
+ end
25
+
26
+ def will_have_attachments_after_save?(record, attribute)
27
+ !Array.wrap(record.send(attribute)).all?(&:marked_for_destruction?)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,80 @@
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
+ # Retrieve the ASV metadata from the blob.
16
+ # If the blob has not been analyzed by our gem yet, the gem will analyze the
17
+ # attachable with the corresponding analyzer and set the metadata in the
18
+ # blob.
19
+ def metadata_for(blob, attachable, metadata_keys)
20
+ return blob.active_storage_validations_metadata if blob_has_asv_metadata?(blob, metadata_keys)
21
+
22
+ new_metadata = generate_metadata_for(attachable, metadata_keys)
23
+ blob.merge_into_active_storage_validations_metadata(new_metadata)
24
+ end
25
+
26
+ def blob_has_asv_metadata?(blob, metadata_keys)
27
+ return false unless blob.active_storage_validations_metadata.present?
28
+
29
+ metadata_keys.all? { |key| blob.active_storage_validations_metadata.key?(key) }
30
+ end
31
+
32
+ def generate_metadata_for(attachable, metadata_keys)
33
+ if metadata_keys == ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
34
+ content_type_analyzer_for(attachable).content_type
35
+ else
36
+ metadata_analyzer_for(attachable).metadata
37
+ end
38
+ end
39
+
40
+ def metadata_analyzer_for(attachable)
41
+ case attachable_media_type(attachable)
42
+ when "image" then image_analyzer_for(attachable)
43
+ when "video" then video_analyzer_for(attachable)
44
+ when "audio" then audio_analyzer_for(attachable)
45
+ else fallback_analyzer_for(attachable)
46
+ end
47
+ end
48
+
49
+ def image_analyzer_for(attachable)
50
+ case image_processor
51
+ when :mini_magick
52
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
53
+ when :vips
54
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
55
+ end
56
+ end
57
+
58
+ def image_processor
59
+ # Rails returns nil for default image processor, because it is set in an after initialize callback
60
+ # https://github.com/rails/rails/blob/main/activestorage/lib/active_storage/engine.rb
61
+ ActiveStorage.variant_processor || DEFAULT_IMAGE_PROCESSOR
62
+ end
63
+
64
+ def video_analyzer_for(attachable)
65
+ ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable)
66
+ end
67
+
68
+ def audio_analyzer_for(attachable)
69
+ ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable)
70
+ end
71
+
72
+ def fallback_analyzer_for(attachable)
73
+ ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
74
+ end
75
+
76
+ def content_type_analyzer_for(attachable)
77
+ ActiveStorageValidations::Analyzer::ContentTypeAnalyzer.new(attachable)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::ASVAttachable
5
+ #
6
+ # Validator methods for analyzing attachable.
7
+ #
8
+ # An attachable is a file representation such as ActiveStorage::Blob,
9
+ # ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, Hash, String,
10
+ # File or Pathname
11
+ module ASVAttachable
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ # Loop through the newly submitted attachables to validate them. Using
17
+ # attachables is the only way to get the attached file io that is necessary
18
+ # to perform file analyses.
19
+ def validate_changed_files_from_metadata(record, attribute, metadata_keys)
20
+ attachables_and_blobs(record, attribute).each do |attachable, blob|
21
+ is_valid?(record, attribute, attachable, metadata_for(blob, attachable, metadata_keys))
22
+ end
23
+ end
24
+
25
+ # Retrieve an array-like of attachables and blobs. Unlike its name suggests,
26
+ # getting attachables from attachment_changes is not getting the changed
27
+ # attachables but all attachables from the `has_many_attached` relation.
28
+ # For the `has_one_attached` relation, it only yields the new attachable,
29
+ # but if we are validating previously attached file, we need to use the blob
30
+ # See #attach at: https://github.com/rails/rails/blob/main/activestorage/lib/active_storage/attached/many.rb
31
+ #
32
+ # Some file could be passed several times, we just need to perform the
33
+ # analysis once on the file, therefore the use of #uniq.
34
+ def attachables_and_blobs(record, attribute)
35
+ changes = if record.public_send(attribute).is_a?(ActiveStorage::Attached::One)
36
+ record.attachment_changes[attribute.to_s].presence || record.public_send(attribute)
37
+ else
38
+ record.attachment_changes[attribute.to_s]
39
+ end
40
+
41
+ return to_enum(:attachables_and_blobs, record, attribute) if changes.blank? || !block_given?
42
+
43
+ if changes.is_a?(ActiveStorage::Attached::Changes::CreateMany)
44
+ changes.attachables.uniq.zip(changes.blobs.uniq).each do |attachable, blob|
45
+ yield attachable, blob
46
+ end
47
+ else
48
+ yield changes.is_a?(ActiveStorage::Attached::Changes::CreateOne) ? changes.attachable : changes.blob, changes.blob
49
+ end
50
+ end
51
+
52
+ # Retrieve the full declared content_type from attachable.
53
+ def full_attachable_content_type(attachable)
54
+ case attachable
55
+ when ActiveStorage::Blob
56
+ attachable.content_type
57
+ when ActionDispatch::Http::UploadedFile
58
+ attachable.content_type
59
+ when Rack::Test::UploadedFile
60
+ attachable.content_type
61
+ when String
62
+ blob = ActiveStorage::Blob.find_signed!(attachable)
63
+ blob.content_type
64
+ when Hash
65
+ attachable[:content_type]
66
+ when File
67
+ supports_file_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
68
+ when Pathname
69
+ supports_pathname_attachment? ? marcel_content_type_from_filename(attachable) : raise_rails_like_error(attachable)
70
+ else
71
+ raise_rails_like_error(attachable)
72
+ end
73
+ end
74
+
75
+ # Retrieve the declared content_type from attachable without potential mime
76
+ # type parameters (e.g. 'application/x-rar-compressed;version=5')
77
+ def attachable_content_type(attachable)
78
+ full_attachable_content_type(attachable) && content_type_without_parameters(full_attachable_content_type(attachable))
79
+ end
80
+
81
+ # Remove the potential mime type parameters from the content_type (e.g.
82
+ # 'application/x-rar-compressed;version=5')
83
+ def content_type_without_parameters(content_type)
84
+ content_type && content_type.downcase.split(/[;,\s]/, 2).first
85
+ end
86
+
87
+ # Retrieve the content_type from attachable using the same logic as Rails
88
+ # ActiveStorage::Blob::Identifiable#identify_content_type
89
+ def attachable_content_type_rails_like(attachable)
90
+ Marcel::MimeType.for(
91
+ attachable_io(attachable, max_byte_size: 4.kilobytes),
92
+ name: attachable_filename(attachable).to_s,
93
+ declared_type: full_attachable_content_type(attachable)
94
+ )
95
+ end
96
+
97
+ # Retrieve the media type of the attachable, which is the first part of the
98
+ # content type (or mime type).
99
+ # Possible values are: application/audio/example/font/image/model/text/video
100
+ def attachable_media_type(attachable)
101
+ (full_attachable_content_type(attachable) || marcel_content_type_from_filename(attachable)).split("/").first
102
+ end
103
+
104
+ # Retrieve the io from attachable.
105
+ def attachable_io(attachable, max_byte_size: nil)
106
+ io = case attachable
107
+ when ActiveStorage::Blob
108
+ (max_byte_size && supports_blob_download_chunk?) ? attachable.download_chunk(0...max_byte_size) : attachable.download
109
+ when ActionDispatch::Http::UploadedFile
110
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
111
+ when Rack::Test::UploadedFile
112
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
113
+ when String
114
+ blob = ActiveStorage::Blob.find_signed!(attachable)
115
+ (max_byte_size && supports_blob_download_chunk?) ? blob.download_chunk(0...max_byte_size) : blob.download
116
+ when Hash
117
+ max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
118
+ when File
119
+ raise_rails_like_error(attachable) unless supports_file_attachment?
120
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
121
+ when Pathname
122
+ raise_rails_like_error(attachable) unless supports_pathname_attachment?
123
+ max_byte_size ? attachable.read(max_byte_size) : attachable.read
124
+ else
125
+ raise_rails_like_error(attachable)
126
+ end
127
+
128
+ rewind_attachable_io(attachable)
129
+ io
130
+ end
131
+
132
+ # Rewind the io attachable.
133
+ def rewind_attachable_io(attachable)
134
+ case attachable
135
+ when ActiveStorage::Blob, String
136
+ # nothing to do
137
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
138
+ attachable.rewind
139
+ when Hash
140
+ attachable[:io].rewind
141
+ when File
142
+ raise_rails_like_error(attachable) unless supports_file_attachment?
143
+ attachable.rewind
144
+ when Pathname
145
+ raise_rails_like_error(attachable) unless supports_pathname_attachment?
146
+ File.open(attachable) { |f| f.rewind }
147
+ else
148
+ raise_rails_like_error(attachable)
149
+ end
150
+ end
151
+
152
+ # Retrieve the declared filename from attachable.
153
+ def attachable_filename(attachable)
154
+ case attachable
155
+ when ActiveStorage::Blob
156
+ attachable.filename
157
+ when ActionDispatch::Http::UploadedFile
158
+ attachable.original_filename
159
+ when Rack::Test::UploadedFile
160
+ attachable.original_filename
161
+ when String
162
+ blob = ActiveStorage::Blob.find_signed!(attachable)
163
+ blob.filename
164
+ when Hash
165
+ attachable[:filename]
166
+ when File
167
+ supports_file_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
168
+ when Pathname
169
+ supports_pathname_attachment? ? File.basename(attachable) : raise_rails_like_error(attachable)
170
+ else
171
+ raise_rails_like_error(attachable)
172
+ end
173
+ end
174
+
175
+ # Raise the same Rails error for not-implemented file representations.
176
+ def raise_rails_like_error(attachable)
177
+ raise(
178
+ ArgumentError,
179
+ "Could not find or build blob: expected attachable, " \
180
+ "got #{attachable.inspect}"
181
+ )
182
+ end
183
+
184
+ # Check if the current Rails version supports File or Pathname attachment
185
+ #
186
+ # https://github.com/rails/rails/blob/7-1-stable/activestorage/CHANGELOG.md#rails-710rc1-september-27-2023
187
+ def supports_file_attachment?
188
+ Rails.gem_version >= Gem::Version.new('7.1.0.rc1')
189
+ end
190
+ alias :supports_pathname_attachment? :supports_file_attachment?
191
+
192
+ # Check if the current Rails version supports ActiveStorage::Blob#download_chunk
193
+ #
194
+ # https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
195
+ def supports_blob_download_chunk?
196
+ Rails.gem_version >= Gem::Version.new('7.0.0.alpha1')
197
+ end
198
+
199
+ # Retrieve the content_type from the file name only
200
+ def marcel_content_type_from_filename(attachable)
201
+ Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
202
+ end
203
+ end
204
+ end