# 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