musaffa/file_validators

View on GitHub
lib/file_validators/validators/file_content_type_validator.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true

module ActiveModel
  module Validations
    class FileContentTypeValidator < ActiveModel::EachValidator
      CHECKS = %i[allow exclude].freeze
      SUPPORTED_MODES = { relaxed: :mime_types, strict: :file }.freeze

      def self.helper_method_name
        :validates_file_content_type
      end

      def validate_each(record, attribute, value)
        begin
          values = parse_values(value)
        rescue JSON::ParserError
          record.errors.add attribute, :invalid
          return
        end

        return if values.empty?

        mode = option_value(record, :mode)
        tool = option_value(record, :tool) || SUPPORTED_MODES[mode]

        allowed_types = option_content_types(record, :allow)
        forbidden_types = option_content_types(record, :exclude)

        values.each do |val|
          content_type = get_content_type(val, tool)
          validate_whitelist(record, attribute, content_type, allowed_types)
          validate_blacklist(record, attribute, content_type, forbidden_types)
        end
      end

      def check_validity!
        unless (CHECKS & options.keys).present?
          raise ArgumentError, 'You must at least pass in :allow or :exclude option'
        end

        options.slice(*CHECKS).each do |option, value|
          unless value.is_a?(String) || value.is_a?(Array) || value.is_a?(Regexp) || value.is_a?(Proc)
            raise ArgumentError, ":#{option} must be a string, an array, a regex or a proc"
          end
        end
      end

      private

      def parse_values(value)
        return [] unless value.present?

        value = JSON.parse(value) if value.is_a?(String)

        Array.wrap(value).reject(&:blank?)
      end

      def get_content_type(value, tool)
        if tool.present?
          FileValidators::MimeTypeAnalyzer.new(tool).call(value)
        else
          value = OpenStruct.new(value) if value.is_a?(Hash)
          value.content_type
        end
      end

      def option_content_types(record, key)
        [option_value(record, key)].flatten.compact
      end

      def option_value(record, key)
        options[key].is_a?(Proc) ? options[key].call(record) : options[key]
      end

      def validate_whitelist(record, attribute, content_type, allowed_types)
        if allowed_types.present? && allowed_types.none? { |type| type === content_type }
          mark_invalid record, attribute, :allowed_file_content_types, allowed_types
        end
      end

      def validate_blacklist(record, attribute, content_type, forbidden_types)
        if forbidden_types.any? { |type| type === content_type }
          mark_invalid record, attribute, :excluded_file_content_types, forbidden_types
        end
      end

      def mark_invalid(record, attribute, error, option_types)
        error_options = options.merge(types: option_types.join(', '))
        unless record.errors.added?(attribute, error, error_options)
          record.errors.add attribute, error, **error_options
        end
      end
    end

    module HelperMethods
      # Places ActiveModel validations on the content type of the file
      # assigned. The possible options are:
      # * +allow+: Allowed content types. Can be a single content type
      #   or an array. Each type can be a String or a Regexp. It can also
      #   be a proc/lambda. It should be noted that Internet Explorer uploads
      #   files with content_types that you may not expect. For example,
      #   JPEG images are given image/pjpeg and PNGs are image/x-png, so keep
      #   that in mind when determining how you match.
      #   Allows all by default.
      # * +exclude+: Forbidden content types.
      # * +message+: The message to display when the uploaded file has an invalid
      #   content type.
      # * +mode+: :strict or :relaxed.
      #   :strict mode validates the content type based on the actual contents
      #   of the files. Thus it can detect media type spoofing.
      #   :relaxed validates the content type based on the file name using
      #   the mime-types gem. It's only for sanity check.
      #   If mode is not set then it uses form supplied content type.
      # * +tool+: :file, :fastimage, :filemagic, :mimemagic, :marcel, :mime_types, :mini_mime
      #   You can choose a different built-in MIME type analyzer
      #   By default supplied content type is used to determine the MIME type
      #   This option have precedence over mode option
      # * +if+: A lambda or name of an instance method. Validation will only
      #   be run is this lambda or method returns true.
      # * +unless+: Same as +if+ but validates if lambda or method returns false.
      def validates_file_content_type(*attr_names)
        validates_with FileContentTypeValidator, _merge_attributes(attr_names)
      end
    end
  end
end