diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb
index 9ca576759bd28..c0effe2e821f6 100644
--- a/actionview/lib/action_view/helpers/form_helper.rb
+++ b/actionview/lib/action_view/helpers/form_helper.rb
@@ -1242,7 +1242,7 @@ def hidden_field(object_name, method, options = {})
def file_field(object_name, method, options = {})
options = { include_hidden: multiple_file_field_include_hidden }.merge!(options)
- Tags::FileField.new(object_name, method, self, convert_direct_upload_option_to_url(options.dup)).render
+ Tags::FileField.new(object_name, method, self, add_direct_upload_attributes(options.dup.merge({ attribute: method }))).render
end
# Returns a textarea opening and closing tag set tailored for accessing a specified attribute (identified by +method+)
diff --git a/actionview/lib/action_view/helpers/form_tag_helper.rb b/actionview/lib/action_view/helpers/form_tag_helper.rb
index 7d08cf8d0174d..e6c772186f073 100644
--- a/actionview/lib/action_view/helpers/form_tag_helper.rb
+++ b/actionview/lib/action_view/helpers/form_tag_helper.rb
@@ -342,7 +342,7 @@ def hidden_field_tag(name, value = nil, options = {})
# file_field_tag 'file', accept: 'text/html', class: 'upload', value: 'index.html'
# # =>
def file_field_tag(name, options = {})
- text_field_tag(name, nil, convert_direct_upload_option_to_url(options.merge(type: :file)))
+ text_field_tag(name, nil, add_direct_upload_attributes(options.merge(type: :file, attribute: name)))
end
# Creates a password field, a masked text field that will hide the users input behind a mask character.
@@ -984,10 +984,18 @@ def set_default_disable_with(value, tag_options)
tag_options.delete("data-disable-with")
end
- def convert_direct_upload_option_to_url(options)
+ def add_direct_upload_attributes(options)
if options.delete(:direct_upload) && respond_to?(:rails_direct_uploads_url)
options["data-direct-upload-url"] = rails_direct_uploads_url
+
+ if options[:object].present? &&
+ options[:attribute].present? &&
+ options[:object].send(options[:attribute]).respond_to?(:to_signed_validation_id)
+ options["data-direct-upload-signed-validation-id"] = options[:object].send(options[:attribute]).to_signed_validation_id
+ end
end
+
+ options.delete(:attribute)
options
end
end
diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md
index 0f6a55f436575..95bec98aa3652 100644
--- a/activestorage/CHANGELOG.md
+++ b/activestorage/CHANGELOG.md
@@ -1,3 +1,9 @@
+* Introduce Active Storage validators. Subclasses of `ActiveStorage::Validations::BaseValidator` run before creating a `Blob` on direct upload, and before saving an `Attachment` via direct or indirect uploads. Includes built in validators for content type and byte size.
+
+ See Active Storage guide for examples.
+
+ *Abhishek Chandrasekhar*, *Alex Ghiculescu*, *Sean Abrahams*
+
* Fixes multiple `attach` calls within transaction not uploading files correctly.
In the following example, the code failed to upload all but the last file to the configured service.
diff --git a/activestorage/README.md b/activestorage/README.md
index 67709c8c2d220..97ce4c72e335d 100644
--- a/activestorage/README.md
+++ b/activestorage/README.md
@@ -105,6 +105,49 @@ Variation of image attachment:
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
```
+## Validations
+
+Active Storage includes attachment validators for the following properties:
+
+* Byte Size
+* Content Type
+
+```ruby
+class User < ActiveRecord::Base
+ has_one_attached :avatar
+
+ # Validating Size
+ # Accepts options for: `:in`, `:minimum`, `:maximum`
+ validates :avatar, attachment_byte_size: { in: 0..1.megabyte }
+ validates :avatar, attachment_byte_size: { minimum: 17.kilobytes }
+ validates :avatar, attachment_byte_size: { maximum: 37.megabytes }
+
+ # If you pass in a range `:in` is assumed and used
+ validates :avatar, attachment_byte_size: 0..1.megabyte
+
+ # Alternative syntax:
+ # validates_attachment :avatar, byte_size: { in: 0..1.megabyte }
+ # validates_attachment :avatar, byte_size: 0..1.megabyte
+ # validates_attachment_byte_size :avatar, in: 0..1.megabyte
+
+ # Validating Content Type
+ # Accepts options for: `:in`, `:not`
+ validates :avatar, attachment_content_type: { in: %w[image/jpeg image/png] }
+ validates :avatar, attachment_content_type: { not: %[image/gif] }
+
+ # If you pass in a string or array `:in` is assumed and used
+ validates :avatar, attachment_content_type: "image/jpeg"
+ validates :avatar, attachment_content_type: %w[image/jpeg image/png]
+
+ # Alternative syntax:
+ # validates_attachment :avatar, content_type: { in: %w[image/jpeg image/png] }
+ # validates_attachment :avatar, content_type: "image/jpeg"
+ # validates_attachment_content_type :avatar, in: %w[image/jpeg image/png]
+end
+```
+
+See the [rails guides](https://edgeguides.rubyonrails.org/active_storage_overview.html#validations) for more information.
+
## File serving strategies
Active Storage supports two ways to serve files: redirecting and proxying.
diff --git a/activestorage/app/assets/javascripts/activestorage.esm.js b/activestorage/app/assets/javascripts/activestorage.esm.js
index caa3a14860bdf..c8151257dabf7 100644
--- a/activestorage/app/assets/javascripts/activestorage.esm.js
+++ b/activestorage/app/assets/javascripts/activestorage.esm.js
@@ -508,13 +508,14 @@ function toArray(value) {
}
class BlobRecord {
- constructor(file, checksum, url) {
+ constructor(file, checksum, url, signedValidationId) {
this.file = file;
this.attributes = {
filename: file.name,
content_type: file.type || "application/octet-stream",
byte_size: file.size,
- checksum: checksum
+ checksum: checksum,
+ signedValidationId: signedValidationId
};
this.xhr = new XMLHttpRequest;
this.xhr.open("POST", url, true);
@@ -604,11 +605,12 @@ class BlobUpload {
let id = 0;
class DirectUpload {
- constructor(file, url, delegate) {
+ constructor(file, url, delegate, signedValidationId) {
this.id = ++id;
this.file = file;
this.url = url;
this.delegate = delegate;
+ this.signedValidationId = signedValidationId;
}
create(callback) {
FileChecksum.create(this.file, ((error, checksum) => {
@@ -616,7 +618,7 @@ class DirectUpload {
callback(error);
return;
}
- const blob = new BlobRecord(this.file, checksum, this.url);
+ const blob = new BlobRecord(this.file, checksum, this.url, this.signedValidationId);
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
blob.create((error => {
if (error) {
@@ -647,7 +649,7 @@ class DirectUploadController {
constructor(input, file) {
this.input = input;
this.file = file;
- this.directUpload = new DirectUpload(this.file, this.url, this);
+ this.directUpload = new DirectUpload(this.file, this.url, this, this.signedValidationId);
this.dispatch("initialize");
}
start(callback) {
@@ -678,6 +680,9 @@ class DirectUploadController {
get url() {
return this.input.getAttribute("data-direct-upload-url");
}
+ get signedValidationId() {
+ return this.input.getAttribute("data-direct-upload-signed-validation-id");
+ }
dispatch(name, detail = {}) {
detail.file = this.file;
detail.id = this.directUpload.id;
diff --git a/activestorage/app/assets/javascripts/activestorage.js b/activestorage/app/assets/javascripts/activestorage.js
index 9e6a5bf79799b..55e8f9e56a0a8 100644
--- a/activestorage/app/assets/javascripts/activestorage.js
+++ b/activestorage/app/assets/javascripts/activestorage.js
@@ -503,13 +503,14 @@
}
}
class BlobRecord {
- constructor(file, checksum, url) {
+ constructor(file, checksum, url, signedValidationId) {
this.file = file;
this.attributes = {
filename: file.name,
content_type: file.type || "application/octet-stream",
byte_size: file.size,
- checksum: checksum
+ checksum: checksum,
+ signedValidationId: signedValidationId
};
this.xhr = new XMLHttpRequest;
this.xhr.open("POST", url, true);
@@ -596,11 +597,12 @@
}
let id = 0;
class DirectUpload {
- constructor(file, url, delegate) {
+ constructor(file, url, delegate, signedValidationId) {
this.id = ++id;
this.file = file;
this.url = url;
this.delegate = delegate;
+ this.signedValidationId = signedValidationId;
}
create(callback) {
FileChecksum.create(this.file, ((error, checksum) => {
@@ -608,7 +610,7 @@
callback(error);
return;
}
- const blob = new BlobRecord(this.file, checksum, this.url);
+ const blob = new BlobRecord(this.file, checksum, this.url, this.signedValidationId);
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
blob.create((error => {
if (error) {
@@ -637,7 +639,7 @@
constructor(input, file) {
this.input = input;
this.file = file;
- this.directUpload = new DirectUpload(this.file, this.url, this);
+ this.directUpload = new DirectUpload(this.file, this.url, this, this.signedValidationId);
this.dispatch("initialize");
}
start(callback) {
@@ -668,6 +670,9 @@
get url() {
return this.input.getAttribute("data-direct-upload-url");
}
+ get signedValidationId() {
+ return this.input.getAttribute("data-direct-upload-signed-validation-id");
+ }
dispatch(name, detail = {}) {
detail.file = this.file;
detail.id = this.directUpload.id;
diff --git a/activestorage/app/controllers/active_storage/direct_uploads_controller.rb b/activestorage/app/controllers/active_storage/direct_uploads_controller.rb
index 99634597f3f90..74b1fec2ea83b 100644
--- a/activestorage/app/controllers/active_storage/direct_uploads_controller.rb
+++ b/activestorage/app/controllers/active_storage/direct_uploads_controller.rb
@@ -5,8 +5,13 @@
# the blob that was created up front.
class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
def create
- blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
- render json: direct_upload_json(blob)
+ blob = ActiveStorage::Blob.build_for_direct_upload(**blob_args)
+ if blob.valid_with?(params.dig(:blob, :signed_validation_id))
+ blob.save!
+ render json: direct_upload_json(blob)
+ else
+ render json: { errors: blob.errors.as_json }, status: :unprocessable_entity
+ end
end
private
diff --git a/activestorage/app/javascript/activestorage/blob_record.js b/activestorage/app/javascript/activestorage/blob_record.js
index 997c123870aee..92919bbf112a5 100644
--- a/activestorage/app/javascript/activestorage/blob_record.js
+++ b/activestorage/app/javascript/activestorage/blob_record.js
@@ -1,14 +1,15 @@
import { getMetaValue } from "./helpers"
export class BlobRecord {
- constructor(file, checksum, url) {
+ constructor(file, checksum, url, signedValidationId) {
this.file = file
this.attributes = {
filename: file.name,
content_type: file.type || "application/octet-stream",
byte_size: file.size,
- checksum: checksum
+ checksum: checksum,
+ signedValidationId: signedValidationId
}
this.xhr = new XMLHttpRequest
diff --git a/activestorage/app/javascript/activestorage/direct_upload.js b/activestorage/app/javascript/activestorage/direct_upload.js
index c2eedf289b807..e6ff09e95b2ca 100644
--- a/activestorage/app/javascript/activestorage/direct_upload.js
+++ b/activestorage/app/javascript/activestorage/direct_upload.js
@@ -5,11 +5,12 @@ import { BlobUpload } from "./blob_upload"
let id = 0
export class DirectUpload {
- constructor(file, url, delegate) {
+ constructor(file, url, delegate, signedValidationId) {
this.id = ++id
this.file = file
this.url = url
this.delegate = delegate
+ this.signedValidationId = signedValidationId
}
create(callback) {
@@ -19,7 +20,7 @@ export class DirectUpload {
return
}
- const blob = new BlobRecord(this.file, checksum, this.url)
+ const blob = new BlobRecord(this.file, checksum, this.url, this.signedValidationId)
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
blob.create(error => {
diff --git a/activestorage/app/javascript/activestorage/direct_upload_controller.js b/activestorage/app/javascript/activestorage/direct_upload_controller.js
index 987050889a750..a89abc1ed210a 100644
--- a/activestorage/app/javascript/activestorage/direct_upload_controller.js
+++ b/activestorage/app/javascript/activestorage/direct_upload_controller.js
@@ -5,7 +5,7 @@ export class DirectUploadController {
constructor(input, file) {
this.input = input
this.file = file
- this.directUpload = new DirectUpload(this.file, this.url, this)
+ this.directUpload = new DirectUpload(this.file, this.url, this, this.signedValidationId)
this.dispatch("initialize")
}
@@ -41,6 +41,10 @@ export class DirectUploadController {
return this.input.getAttribute("data-direct-upload-url")
}
+ get signedValidationId() {
+ return this.input.getAttribute("data-direct-upload-signed-validation-id")
+ }
+
dispatch(name, detail = {}) {
detail.file = this.file
detail.id = this.directUpload.id
diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb
index 5f64e28257eac..795bfaae6bf1d 100644
--- a/activestorage/app/models/active_storage/blob.rb
+++ b/activestorage/app/models/active_storage/blob.rb
@@ -114,7 +114,16 @@ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: ni
# Once the form using the direct upload is submitted, the blob can be associated with the right record using
# the signed ID.
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
- create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
+ build_for_direct_upload(key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name).tap(&:save!)
+ end
+
+ # Returns a blob _without_ uploading a file to the service. This blob will point to a key where there is
+ # no file yet. It's intended to be used together with a client-side upload, which will first create the blob
+ # in order to produce the signed URL for uploading. This signed URL points to the key generated by the blob.
+ # Once the form using the direct upload is submitted, the blob can be associated with the right record using
+ # the signed ID.
+ def build_for_direct_upload(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
+ new key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
end
# To prevent problems with case-insensitive filesystems, especially in combination
@@ -365,6 +374,28 @@ def content_type=(value)
INVALID_VARIABLE_CONTENT_TYPES_DEPRECATED_IN_RAILS_7 = ["image/jpg", "image/pjpeg"]
INVALID_VARIABLE_CONTENT_TYPES_TO_SERVE_AS_BINARY_DEPRECATED_IN_RAILS_7 = ["text/javascript"]
+ # Given a model that descends from ActiveRecord::Base, returns true if the blob would make a valid attachment to that model.
+ # An attachment would be valid if Active Storage validators (subclassing ActiveStorage::Validations::BaseValidator) would pass.
+ #
+ # Returns false if the model cannot be found.
+ # Returns true if no validators are configured on the model.
+ def valid_with?(signed_validation_id = nil)
+ return true if signed_validation_id.nil?
+
+ model_gid, attribute = ActiveStorage.verifier.verified(signed_validation_id).split("--")
+ model = GlobalID::Locator.locate(model_gid)
+
+ # When model_gid is a class name pointing to an unpersisted model
+ # model_gid => "User"
+ model ||= ActiveRecord::Base.const_get(model_gid).new
+
+ model.class.validators.select { |v| v.is_a?(ActiveStorage::Validations::BaseValidator) && v.attributes.include?(attribute.to_sym) }.each do |validator|
+ validator.valid_with?(self, model, attribute)
+ end
+
+ self.errors.blank?
+ end
+
private
def compute_checksum_in_chunks(io)
OpenSSL::Digest::MD5.new.tap do |checksum|
diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb
index ab06a37081a23..4a97c6bbefdef 100644
--- a/activestorage/lib/active_storage.rb
+++ b/activestorage/lib/active_storage.rb
@@ -371,3 +371,7 @@ module Transformers
autoload :ImageProcessingTransformer
end
end
+
+ActiveSupport.on_load(:i18n) do
+ I18n.load_path << File.expand_path("active_storage/locale/en.yml", __dir__)
+end
diff --git a/activestorage/lib/active_storage/attached.rb b/activestorage/lib/active_storage/attached.rb
index b540f85fbefe6..8f782b704d9e5 100644
--- a/activestorage/lib/active_storage/attached.rb
+++ b/activestorage/lib/active_storage/attached.rb
@@ -12,6 +12,14 @@ def initialize(name, record)
@name, @record = name, record
end
+ def to_signed_validation_id
+ if record.persisted?
+ ActiveStorage.verifier.generate("#{record.to_global_id}--#{name}")
+ else
+ ActiveStorage.verifier.generate("#{record.model_name}--#{name}")
+ end
+ end
+
private
def change
record.attachment_changes[name]
diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb
index e6d3c0a68f2ed..94c194ff4cb1c 100644
--- a/activestorage/lib/active_storage/engine.rb
+++ b/activestorage/lib/active_storage/engine.rb
@@ -21,6 +21,8 @@
require "active_storage/reflection"
+require "active_storage/validations"
+
module ActiveStorage
class Engine < Rails::Engine # :nodoc:
isolate_namespace ActiveStorage
@@ -129,6 +131,7 @@ class Engine < Rails::Engine # :nodoc:
ActiveSupport.on_load(:active_record) do
include ActiveStorage::Attached::Model
+ include ActiveStorage::Validations
end
end
diff --git a/activestorage/lib/active_storage/locale/en.yml b/activestorage/lib/active_storage/locale/en.yml
new file mode 100644
index 0000000000000..cd3b2eeb4d275
--- /dev/null
+++ b/activestorage/lib/active_storage/locale/en.yml
@@ -0,0 +1,8 @@
+en:
+ errors:
+ # The values :model, :attribute ,and :value are always available for interpolation
+ # The value :count is available when applicable. Can be used for pluralization.
+ messages:
+ in_between: "must be between %{minimum} and %{maximum}"
+ minimum: "must be greater than or equal to %{minimum}"
+ maximum: "must be less than or equal to %{maximum}"
diff --git a/activestorage/lib/active_storage/validations.rb b/activestorage/lib/active_storage/validations.rb
new file mode 100644
index 0000000000000..0680e382e31e4
--- /dev/null
+++ b/activestorage/lib/active_storage/validations.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require "active_model"
+require "active_support/concern"
+require "active_support/core_ext/array/wrap"
+require "active_storage/validations/base_validator"
+require "active_storage/validations/attachment_byte_size_validator"
+require "active_storage/validations/attachment_content_type_validator"
+require "active_storage/validations/attachment_presence_validator"
+
+module ActiveStorage
+ # Provides the class-level DSL for declaring ActiveStorage validations
+ module Validations
+ extend ActiveSupport::Concern
+
+ included do
+ extend HelperMethods
+ include HelperMethods
+ end
+
+ module ClassMethods
+ # A helper method to run various Active Storage attachment validators.
+ #
+ # Effectively the same as the ActiveModel::Validations#validates
+ # method but more readable since it does not require the +attachment_+
+ # prefix for its keys.
+ #
+ # validates_attachment :avatar, size: { in: 2..4.megabytes }
+ # validates_attachment :avatar, content_type: { in: "image/jpeg" }
+ #
+ # Like the ActiveModel::Validations#validates, it also
+ # supports shortcut options which can handle ranges, arrays, and strings.
+ #
+ # validates_attachment :avatar, size: 2..4.megabytes
+ # validates_attachment :avatar, content_type: "image/jpeg"
+ #
+ # When using shortcut form, ranges and arrays are passed to the
+ # validator as if they were specified with the +:in+ option, while other
+ # types including regular expressions and strings are passed as if they
+ # were specified using +:with+.
+ def validates_attachment(*attributes)
+ options = attributes.extract_options!.dup
+
+ ActiveStorage::Validations.constants.each do |constant|
+ if constant.to_s =~ /\AAttachment(.+)Validator\z/
+ validator_kind = $1.underscore.to_sym
+
+ if options.has_key?(validator_kind)
+ validator_options = options.delete(validator_kind)
+ validator_options = parse_shortcut_options(validator_options)
+
+ conditional_options = options.slice(:if, :unless)
+
+ Array.wrap(validator_options).each do |local_options|
+ method_name = ActiveStorage::Validations.const_get(constant.to_s).helper_method_name
+ send(method_name, attributes, local_options.merge(conditional_options))
+ end
+ end
+ end
+ end
+ end
+
+ private
+ def parse_shortcut_options(options)
+ _parse_validates_options(options)
+ end
+ end
+ end
+end
diff --git a/activestorage/lib/active_storage/validations/attachment_byte_size_validator.rb b/activestorage/lib/active_storage/validations/attachment_byte_size_validator.rb
new file mode 100644
index 0000000000000..dfc8b56844430
--- /dev/null
+++ b/activestorage/lib/active_storage/validations/attachment_byte_size_validator.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+module ActiveStorage
+ module Validations
+ class AttachmentByteSizeValidator < BaseValidator
+ AVAILABLE_CHECKS = %i[minimum maximum in]
+
+ def self.helper_method_name
+ :validates_attachment_byte_size
+ end
+
+ def initialize(options = {})
+ super
+ error_options.merge!(
+ minimum: to_human_size(minimum),
+ maximum: to_human_size(maximum)
+ )
+ end
+
+ def check_validity!
+ if options_blank?
+ raise(
+ ArgumentError,
+ "You must pass either :minimum, :maximum, or :in to the validator"
+ )
+ end
+
+ if options_redundant?
+ raise(
+ ArgumentError,
+ "Cannot pass :minimum or :maximum if already passing :in"
+ )
+ end
+ end
+
+ private
+ def blob_field_name
+ :byte_size
+ end
+
+ def error_key_for(check_name)
+ check_name == :in ? :in_between : check_name
+ end
+
+ def options_redundant?
+ options.has_key?(:in) &&
+ (options.has_key?(:minimum) || options.has_key?(:minimum))
+ end
+
+ def minimum
+ @minimum ||= options[:minimum] || options[:in].try(:min) || 0
+ end
+
+ def maximum
+ @maximum ||= options[:maximum] || options[:in].try(:max) || Float::INFINITY
+ end
+
+ def to_human_size(size)
+ return "∞" if size == Float::INFINITY
+ ActiveSupport::NumberHelper.number_to_human_size(size)
+ end
+
+ def passes_check?(blob, check_name, check_value)
+ case check_name.to_sym
+ when :in
+ check_value.include?(blob.byte_size)
+ when :minimum
+ blob.byte_size >= check_value
+ when :maximum
+ blob.byte_size <= check_value
+ end
+ end
+ end
+
+ module HelperMethods
+ # Validates the size (in bytes) of the ActiveStorage attachments. Happens
+ # by default on save.
+ #
+ # class Employee < ActiveRecord::Base
+ # has_one_attached :avatar
+ #
+ # validates_attachment_byte_size :avatar, in: 0..2.megabytes
+ # end
+ #
+ # Configuration options:
+ # * in - a +Range+ of bytes (e.g. +0..1.megabyte+),
+ # * maximum - equivalent to +in: 0..options[:maximum]+
+ # * minimum - equivalent to +in: options[:minimum]..Infinity+
+ # * :message - A custom error message which overrides the
+ # default error message. The following keys are available for
+ # interpolation within the message: +model+, +attribute+, +value+,
+ # +minimum+, and +maximum+.
+ #
+ # There is also a list of default options supported by every validator:
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
+ # See ActiveModel::Validations#validates for more information
+ def validates_attachment_byte_size(*attributes)
+ validates_with AttachmentByteSizeValidator, _merge_attributes(attributes)
+ end
+ end
+ end
+end
diff --git a/activestorage/lib/active_storage/validations/attachment_content_type_validator.rb b/activestorage/lib/active_storage/validations/attachment_content_type_validator.rb
new file mode 100644
index 0000000000000..bb1fb11edd29d
--- /dev/null
+++ b/activestorage/lib/active_storage/validations/attachment_content_type_validator.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module ActiveStorage
+ module Validations
+ class AttachmentContentTypeValidator < BaseValidator
+ AVAILABLE_CHECKS = %i[in not with]
+
+ def self.helper_method_name
+ :validates_attachment_content_type
+ end
+
+ def check_validity!
+ if options_blank?
+ raise(
+ ArgumentError,
+ "You must pass at least one of #{AVAILABLE_CHECKS.join(", ")} to the validator"
+ )
+ end
+
+ if options_redundant?
+ raise(ArgumentError, "Cannot pass both :in and :not")
+ end
+ end
+
+ private
+ def blob_field_name
+ :content_type
+ end
+
+ def error_key_for(check_name)
+ { in: :inclusion, not: :exclusion, with: :inclusion }[check_name.to_sym]
+ end
+
+ def options_redundant?
+ options.has_key?(:in) && options.has_key?(:not)
+ end
+
+ def passes_check?(blob, check_name, check_value)
+ case check_name.to_sym
+ when :in
+ check_value.include?(blob.content_type)
+ when :not
+ !check_value.include?(blob.content_type)
+ when :with
+ # TODO: implement check_options_validity from AM::Validators::FormatValidator
+ # QUESTION: How best to implement check_options_validity? Going with copy+paste for now.
+ check_value = Regexp.new(check_value) if check_value.is_a?(String)
+ if check_value.is_a?(Regexp)
+ if check_value.source.start_with?("^") || (check_value.source.end_with?("$") && !check_value.source.end_with?("\\$"))
+ raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \
+ "which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \
+ ":multiline => true option?"
+ end
+ check_value.match?(blob.content_type)
+ elsif check_value.respond_to?(:call)
+ passes_check?(blob, :in, check_value.call(@record))
+ else
+ raise ArgumentError, "A regular expression, proc, or lambda must be supplied to :with"
+ end
+ end
+ end
+ end
+
+ module HelperMethods
+ # Validates the content type of the ActiveStorage attachments. Happens by
+ # default on save.
+ #
+ # class Employee < ActiveRecord::Base
+ # has_one_attached :avatar
+ #
+ # validates_attachment_content_type :avatar, in: %w[image/jpeg audio/ogg]
+ # validates_attachment_content_type :avatar, in: "image/jpeg"
+ # end
+ #
+ # Configuration options:
+ # * in - a +Array+ or +String+ of allowable content types
+ # * not - a +Array+ or +String+ of content types to exclude
+ # * :message - A custom error message which overrides the
+ # default error message.
+ #
+ # There is also a list of default options supported by every validator:
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
+ # See ActiveModel::Validations#validates for more information
+ def validates_attachment_content_type(*attributes)
+ validates_with AttachmentContentTypeValidator, _merge_attributes(attributes)
+ end
+ end
+ end
+end
diff --git a/activestorage/lib/active_storage/validations/attachment_presence_validator.rb b/activestorage/lib/active_storage/validations/attachment_presence_validator.rb
new file mode 100644
index 0000000000000..8e89ff770ede1
--- /dev/null
+++ b/activestorage/lib/active_storage/validations/attachment_presence_validator.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module ActiveStorage
+ module Validations
+ class AttachmentPresenceValidator < BaseValidator
+ AVAILABLE_CHECKS = []
+
+ def self.helper_method_name
+ :validates_attachment_presence
+ end
+
+ def validate_each(record, attribute, _value)
+ return if record.send(attribute).attached?
+
+ record.errors.add(attribute, :blank, **options)
+ end
+
+ private
+ def error_key_for(check_name)
+ ## Not Required
+ end
+
+ def options_redundant?
+ ## Not Required
+ end
+
+ def passes_check?(blob, check_name, check_value)
+ ## Not Required
+ end
+ end
+
+ module HelperMethods
+ # Validates the presence of the ActiveStorage attachments. Happens by
+ # default on save.
+ #
+ # class Employee < ActiveRecord::Base
+ # has_one_attached :avatar
+ #
+ # validates_attachment_presence :avatar
+ # end
+ #
+ # Configuration options:
+ # * :message - A custom error message which overrides the
+ # default error message.
+ #
+ # There is also a list of default options supported by every validator:
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
+ # See ActiveModel::Validations#validates for more information
+ def validates_attachment_presence(*attributes)
+ validates_with AttachmentPresenceValidator, _merge_attributes(attributes)
+ end
+ end
+ end
+end
diff --git a/activestorage/lib/active_storage/validations/base_validator.rb b/activestorage/lib/active_storage/validations/base_validator.rb
new file mode 100644
index 0000000000000..574e3559e627f
--- /dev/null
+++ b/activestorage/lib/active_storage/validations/base_validator.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+module ActiveStorage
+ module Validations
+ class BaseValidator < ActiveModel::EachValidator
+ def initialize(options = {})
+ super
+ @error_options = { message: options[:message] }
+ end
+
+ def valid_with?(blob, record=nil, attribute=nil)
+ @record ||= record
+ valid = true
+ each_check do |check_name, check_value|
+ next if passes_check?(blob, check_name, check_value)
+
+ # If we're validating a blob directly, not through their Attachable
+ if attribute.present?
+ blob.errors.add(blob_field_name, error_key_for(check_name), **error_options)
+ else
+ @record.errors.add(@name, error_key_for(check_name), **error_options)
+ end
+ valid = false
+ end
+ valid
+ end
+
+ def validate_each(record, attribute, _value)
+ @record = record
+ @name = attribute
+
+ each_blob do |blob|
+ valid_with?(blob)
+ end
+ end
+
+ private
+ attr_reader :error_options
+
+ def available_checks
+ self.class::AVAILABLE_CHECKS
+ end
+
+ def blob_field_name
+ :base
+ end
+
+ def options_blank?
+ available_checks.none? { |arg| options.has_key?(arg) }
+ end
+
+ def options_redundant?
+ raise NotImplementedError, "Subclasses must implement an options_redundant? method"
+ end
+
+ def error_key_for(check_name)
+ raise NotImplementedError, "Subclasses must implement error_key_for(check_name)"
+ end
+
+ def each_blob(&block)
+ changes = attachment_changes
+
+ blobs =
+ case
+ when marked_for_creation? then changes.try(:blob) || changes.blobs
+ when marked_for_deletion? then []
+ else
+ @record.send(blob_association)
+ end
+
+ blobs = [blobs].flatten.compact
+ blobs.each { |blob| yield(blob) }
+ end
+
+ def each_check(&block)
+ options.slice(*available_checks).each do |name, value|
+ yield(name, value)
+ end
+ end
+
+ def passes_check?(blob, check_name, check_value)
+ raise NotImplementedError, "Subclasses must implement a passes_check?(blob, check_name, check_value) method"
+ end
+
+ def attachment_changes
+ @attachment_changes ||= @record.attachment_changes[@name.to_s]
+ end
+
+ def marked_for_creation?
+ [
+ ActiveStorage::Attached::Changes::CreateOne,
+ ActiveStorage::Attached::Changes::CreateMany
+ ].include?(attachment_changes.class)
+ end
+
+ def marked_for_deletion?
+ [
+ ActiveStorage::Attached::Changes::DeleteOne,
+ ActiveStorage::Attached::Changes::DeleteMany
+ ].include?(attachment_changes.class)
+ end
+
+ def blob_association
+ @record.respond_to?("#{@name}_blob") ? "#{@name}_blob" : "#{@name}_blobs"
+ end
+ end
+ end
+end
diff --git a/activestorage/test/controllers/direct_uploads_controller_test.rb b/activestorage/test/controllers/direct_uploads_controller_test.rb
index 38d2bd542b6bf..a0b83ad799d64 100644
--- a/activestorage/test/controllers/direct_uploads_controller_test.rb
+++ b/activestorage/test/controllers/direct_uploads_controller_test.rb
@@ -135,6 +135,22 @@ class ActiveStorage::AzureStorageDirectUploadsControllerTest < ActionDispatch::I
end
class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @old_validators = User._validators.deep_dup
+ @old_callbacks = User._validate_callbacks.deep_dup
+ end
+
+ teardown do
+ User.destroy_all
+ ActiveStorage::Blob.all.each(&:purge)
+
+ User.clear_validators!
+ # NOTE: `clear_validators!` clears both registered validators and any
+ # callbacks registered by `validate()`, so ensure that both are restored
+ User._validators = @old_validators if @old_validators
+ User._validate_callbacks = @old_callbacks if @old_callbacks
+ end
+
test "creating new direct upload" do
checksum = OpenSSL::Digest::MD5.base64digest("Hello")
metadata = {
@@ -148,6 +164,8 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati
post rails_direct_uploads_url, params: { blob: {
filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } }
+ assert_response :success
+
@response.parsed_body.tap do |details|
assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
assert_equal "hello.txt", details["filename"]
@@ -181,6 +199,235 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati
end
end
+ test "creating new direct upload with model with no active storage validations" do
+ # validations that aren't active storage validations are ignored
+ User.validates :name, length: { minimum: 2 }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :success
+
+ @response.parsed_body.tap do |details|
+ assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
+ assert_equal "racecar.jpg", details["filename"]
+ assert_equal file.size, details["byte_size"]
+ assert_equal checksum, details["checksum"]
+ assert_equal metadata, details["metadata"].transform_keys(&:to_sym)
+ assert_equal "image/jpeg", details["content_type"]
+ assert_match(/rails\/active_storage\/disk/, details["direct_upload"]["url"])
+ assert_equal({ "Content-Type" => "image/jpeg" }, details["direct_upload"]["headers"])
+ end
+ end
+
+ test "creating new direct upload with model where validations pass" do
+ User.validates :avatar, attachment_content_type: { with: /\Aimage\//, message: "must be an image" }
+ User.validates :avatar, attachment_byte_size: { maximum: 50.megabytes, message: "can't be larger than 50 MB" }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :success
+
+ @response.parsed_body.tap do |details|
+ assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
+ assert_equal "racecar.jpg", details["filename"]
+ assert_equal file.size, details["byte_size"]
+ assert_equal checksum, details["checksum"]
+ assert_equal metadata, details["metadata"].transform_keys(&:to_sym)
+ assert_equal "image/jpeg", details["content_type"]
+ assert_match(/rails\/active_storage\/disk/, details["direct_upload"]["url"])
+ assert_equal({ "Content-Type" => "image/jpeg" }, details["direct_upload"]["headers"])
+ end
+ end
+
+ test "creating new direct upload with model where validations fail" do
+ User.validates :avatar, attachment_content_type: { with: /\Atext\// }
+ User.validates :avatar, attachment_byte_size: { minimum: 50.megabytes }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :unprocessable_entity
+
+ @response.parsed_body.tap do |details|
+ assert_equal Hash[
+ "content_type" => [
+ "is not included in the list",
+ ],
+ "byte_size" => [
+ "must be greater than or equal to 50 MB",
+ ],
+ ], details["errors"]
+ end
+ end
+
+ test "creating new direct upload with model where validations fail with custom messages" do
+ User.validates :avatar, attachment_content_type: { with: /\Atext\//, message: "must be a text file" }
+ User.validates :avatar, attachment_byte_size: { minimum: 50.megabytes, message: "can't be smaller than 50 MB" }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :unprocessable_entity
+
+ @response.parsed_body.tap do |details|
+ assert_equal Hash[
+ "content_type" => [
+ "must be a text file",
+ ],
+ "byte_size" => [
+ "can't be smaller than 50 MB",
+ ],
+ ], details["errors"]
+ end
+ end
+
+ test "creating new direct upload with model where validation uses proc and succeeds" do
+ User.validates :avatar, attachment_content_type: { with: Proc.new { |user| user.persisted? ? %w[image/png] : %w[image/jpeg] } }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :success
+
+ @response.parsed_body.tap do |details|
+ assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
+ assert_equal "racecar.jpg", details["filename"]
+ assert_equal file.size, details["byte_size"]
+ assert_equal checksum, details["checksum"]
+ assert_equal metadata, details["metadata"].transform_keys(&:to_sym)
+ assert_equal "image/jpeg", details["content_type"]
+ assert_match(/rails\/active_storage\/disk/, details["direct_upload"]["url"])
+ assert_equal({ "Content-Type" => "image/jpeg" }, details["direct_upload"]["headers"])
+ end
+ end
+
+ test "creating new direct upload with model where validation uses proc and fails" do
+ User.validates :avatar, attachment_content_type: { with: Proc.new { |user| user.persisted? ? %w[image/jpeg] : %w[image/png] } }
+
+ file = file_fixture("racecar.jpg").open
+ checksum = Digest::MD5.base64digest(file.read)
+ metadata = {
+ "foo": "bar",
+ "my_key_1": "my_value_1",
+ "my_key_2": "my_value_2",
+ "platform": "my_platform",
+ "library_ID": "12345"
+ }
+
+ post rails_direct_uploads_url, params: {
+ blob: {
+ filename: "racecar.jpg",
+ byte_size: file.size,
+ checksum: checksum,
+ content_type: "image/jpeg",
+ metadata: metadata,
+ signed_validation_id: User.new.avatar.to_signed_validation_id,
+ }
+ }
+
+ assert_response :unprocessable_entity
+
+ @response.parsed_body.tap do |details|
+ assert_equal Hash[
+ "content_type" => [
+ "is not included in the list",
+ ],
+ ], details["errors"]
+ end
+ end
+
private
def set_include_root_in_json(value)
original = ActiveRecord::Base.include_root_in_json
diff --git a/activestorage/test/models/validations/attachment_byte_size_validator_test.rb b/activestorage/test/models/validations/attachment_byte_size_validator_test.rb
new file mode 100644
index 0000000000000..a3b0ace57249d
--- /dev/null
+++ b/activestorage/test/models/validations/attachment_byte_size_validator_test.rb
@@ -0,0 +1,398 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "database/setup"
+
+class ActiveStorage::AttachmentByteSizeValidatorTest < ActiveSupport::TestCase
+ VALIDATOR = ActiveStorage::Validations::AttachmentByteSizeValidator
+
+ setup do
+ @old_validators = User._validators.deep_dup
+ @old_callbacks = User._validate_callbacks.deep_dup
+
+ @blob = create_blob
+ @user = User.create(name: "Anjali")
+
+ @byte_size = @blob.byte_size
+
+ @minimum = @byte_size - 1
+ @maximum = @byte_size + 1
+ @range = @minimum..@maximum
+
+ @bad_minimum = 50.gigabytes
+ @bad_maximum = 1.byte
+ @bad_range = 50.gigabytes..51.gigabytes
+ end
+
+ teardown do
+ User.destroy_all
+ ActiveStorage::Blob.all.each(&:purge)
+
+ User.clear_validators!
+ # NOTE: `clear_validators!` clears both registered validators and any
+ # callbacks registered by `validate()`, so ensure that both are restored
+ User._validators = @old_validators if @old_validators
+ User._validate_callbacks = @old_callbacks if @old_callbacks
+ end
+
+ test "record has no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ assert @user.save
+ end
+
+ test "new record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ @user = User.new(name: "Rohini")
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ assert @user.save
+ end
+
+ test "persisted record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating attachments" do
+ other_blob = create_blob
+ @user.avatar.attach(other_blob)
+ @user.highlights.attach(other_blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating some other field" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ @user.name = "Rohini"
+
+ assert @user.save
+ end
+
+ test "persisted record, destroying attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert @user.save
+ end
+
+ test "destroying record with attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @range)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "new record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ @user = User.new(name: "Rohini")
+
+ assert @user.save
+ end
+
+ test "persisted record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ assert @user.save
+ end
+
+ test "destroying record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range)
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "specifying :minimum option" do
+ User.validates_with(VALIDATOR, attributes: :avatar, minimum: @bad_minimum)
+ User.validates_with(VALIDATOR, attributes: :highlights, minimum: @bad_minimum)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be greater than or equal to 50 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be greater than or equal to 50 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, minimum: @minimum)
+ User.validates_with(VALIDATOR, attributes: :highlights, minimum: @minimum)
+
+ assert @user.save
+ end
+
+ test "specifying :maximum option" do
+ User.validates_with(VALIDATOR, attributes: :avatar, maximum: @bad_maximum)
+ User.validates_with(VALIDATOR, attributes: :highlights, maximum: @bad_maximum)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be less than or equal to 1 Byte"], @user.errors.messages[:avatar]
+ assert_equal ["must be less than or equal to 1 Byte"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, maximum: @maximum)
+ User.validates_with(VALIDATOR, attributes: :highlights, maximum: @maximum)
+
+ assert @user.save
+ end
+
+ test "specifying both :minimum and :maximum options" do
+ User.validates_with(VALIDATOR, attributes: :avatar, minimum: @bad_minimum, maximum: @bad_maximum)
+ User.validates_with(VALIDATOR, attributes: :highlights, minimum: @bad_minimum, maximum: @bad_maximum)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ errors = ["must be greater than or equal to 50 GB",
+ "must be less than or equal to 1 Byte"]
+ assert_equal errors, @user.errors.messages[:avatar]
+ assert_equal errors, @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, minimum: @minimum, maximum: @maximum)
+ User.validates_with(VALIDATOR, attributes: :highlights, minimum: @minimum, maximum: @maximum)
+
+ assert @user.save
+ end
+
+ test "specifying no options" do
+ exception = assert_raise(ArgumentError) do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ end
+
+ assert_equal(
+ "You must pass either :minimum, :maximum, or :in to the validator",
+ exception.message
+ )
+ end
+
+ test "specifying redundant options" do
+ exception = assert_raise(ArgumentError) do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @range, minimum: @minimum)
+ end
+
+ assert_equal(
+ "Cannot pass :minimum or :maximum if already passing :in",
+ exception.message
+ )
+ end
+
+ test "validating with `validates()`" do
+ User.validates(:avatar, attachment_byte_size: { in: @bad_range })
+ User.validates(:highlights, attachment_byte_size: { in: @bad_range })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_byte_size: { in: @range })
+ User.validates(:highlights, attachment_byte_size: { in: @range })
+
+ assert @user.save
+ end
+
+ test "validating with `validates()`, Range shortcut option" do
+ User.validates(:avatar, attachment_byte_size: @bad_range)
+ User.validates(:highlights, attachment_byte_size: @bad_range)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_byte_size: @range)
+ User.validates(:highlights, attachment_byte_size: @range)
+
+ assert @user.save
+ end
+
+ test "validating with `validates()`, invalid shortcut option" do
+ exception = assert_raise(ArgumentError) do
+ User.validates(:avatar, attachment_byte_size: "foo")
+ end
+
+ assert_equal(
+ "You must pass either :minimum, :maximum, or :in to the validator",
+ exception.message
+ )
+ end
+
+ test "validating with `validates_attachment()`" do
+ User.validates_attachment(:avatar, byte_size: { in: @bad_range })
+ User.validates_attachment(:highlights, byte_size: { in: @bad_range })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment(:avatar, byte_size: { in: @range })
+ User.validates_attachment(:highlights, byte_size: { in: @range })
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`, Range shortcut option" do
+ User.validates_attachment(:avatar, byte_size: @bad_range)
+ User.validates_attachment(:highlights, byte_size: @bad_range)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment(:avatar, byte_size: @range)
+ User.validates_attachment(:highlights, byte_size: @range)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`, invalid shortcut option" do
+ exception = assert_raise(ArgumentError) do
+ User.validates_attachment(:avatar, byte_size: "foo")
+ end
+
+ assert_equal(
+ "You must pass either :minimum, :maximum, or :in to the validator",
+ exception.message
+ )
+ end
+
+ test "validating with `validates_attachment_byte_size()`" do
+ User.validates_attachment_byte_size(:avatar, in: @bad_range)
+ User.validates_attachment_byte_size(:highlights, in: @bad_range)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:avatar]
+ assert_equal ["must be between 50 GB and 51 GB"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment_byte_size(:avatar, in: @range)
+ User.validates_attachment_byte_size(:highlights, in: @range)
+
+ assert @user.save
+ end
+
+ test "specifying a :message option" do
+ message = "Validating %{model}#%{attribute}. The min is %{minimum} and "\
+ "the max is %{maximum}"
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range, message: message)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range, message: message)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal(
+ ["Validating User#Avatar. The min is 50 GB and the max is 51 GB"],
+ @user.errors.messages[:avatar]
+ )
+ assert_equal(
+ ["Validating User#Highlights. The min is 50 GB and the max is 51 GB"],
+ @user.errors.messages[:highlights]
+ )
+ end
+
+ test "inheritance of default ActiveModel options" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_range, if: Proc.new { false })
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_range, if: Proc.new { false })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+ end
+end
diff --git a/activestorage/test/models/validations/attachment_content_type_validator_test.rb b/activestorage/test/models/validations/attachment_content_type_validator_test.rb
new file mode 100644
index 0000000000000..84e2b572b392a
--- /dev/null
+++ b/activestorage/test/models/validations/attachment_content_type_validator_test.rb
@@ -0,0 +1,444 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "database/setup"
+
+class ActiveStorage::AttachmentContentTypeValidatorTest < ActiveSupport::TestCase
+ VALIDATOR = ActiveStorage::Validations::AttachmentContentTypeValidator
+
+ setup do
+ @old_validators = User._validators.deep_dup
+ @old_callbacks = User._validate_callbacks.deep_dup
+
+ @blob = create_blob
+ @user = User.create(name: "Anjali")
+
+ @content_types = %w[text/plain image/jpeg]
+ @bad_content_types = %w[audio/ogg application/pdf]
+ end
+
+ teardown do
+ User.destroy_all
+ ActiveStorage::Blob.all.each(&:purge)
+
+ User.clear_validators!
+ # NOTE: `clear_validators!` clears both registered validators and any
+ # callbacks registered by `validate()`, so ensure that both are restored
+ User._validators = @old_validators if @old_validators
+ User._validate_callbacks = @old_callbacks if @old_callbacks
+ end
+
+ test "record has no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ assert @user.save
+ end
+
+ test "new record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ @user = User.new(name: "Rohini")
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ assert @user.save
+ end
+
+ test "persisted record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating attachments" do
+ old_blob = create_blob
+ @user.avatar.attach(old_blob)
+ @user.highlights.attach(old_blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating some other field" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ @user.name = "Rohini"
+
+ assert @user.save
+ end
+
+ test "persisted record, destroying attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert @user.save
+ end
+
+ test "destroying record with attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "new record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ @user = User.new(name: "Rohini")
+
+ assert @user.save
+ end
+
+ test "persisted record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ assert @user.save
+ end
+
+ test "destroying record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types)
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "specifying :in option as String" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types.first)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types.first)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types.first)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @content_types.first)
+
+ assert @user.save
+ end
+
+ test "specifying :not option" do
+ User.validates_with(VALIDATOR, attributes: :avatar, not: @content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, not: @content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is reserved"], @user.errors.messages[:avatar]
+ assert_equal ["is reserved"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, not: @bad_content_types)
+ User.validates_with(VALIDATOR, attributes: :highlights, not: @bad_content_types)
+
+ assert @user.save
+ end
+
+ test "specifying :not option as a String" do
+ User.validates_with(VALIDATOR, attributes: :avatar, not: @content_types.first)
+ User.validates_with(VALIDATOR, attributes: :highlights, not: @content_types.first)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is reserved"], @user.errors.messages[:avatar]
+ assert_equal ["is reserved"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar, not: @bad_content_types.first)
+ User.validates_with(VALIDATOR, attributes: :highlights, not: @bad_content_types.first)
+
+ assert @user.save
+ end
+
+ test "specifying no options" do
+ exception = assert_raise(ArgumentError) do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ end
+
+ assert_equal(
+ "You must pass at least one of in, not, with to the validator",
+ exception.message
+ )
+ end
+
+ test "specifying redundant options" do
+ exception = assert_raise(ArgumentError) do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @content_types, not: @bad_content_types)
+ end
+
+ assert_equal("Cannot pass both :in and :not", exception.message)
+ end
+
+ test "validating with `validates()`" do
+ User.validates(:avatar, attachment_content_type: { in: @bad_content_types })
+ User.validates(:highlights, attachment_content_type: { in: @bad_content_types })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_content_type: { in: @content_types })
+ User.validates(:highlights, attachment_content_type: { in: @content_types })
+
+ assert @user.save
+ end
+
+ test "validating with `validates()`, String shortcut option" do
+ User.validates(:avatar, attachment_content_type: @bad_content_types.first)
+ User.validates(:highlights, attachment_content_type: @bad_content_types.first)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_content_type: @content_types.first)
+ User.validates(:highlights, attachment_content_type: @content_types.first)
+
+ assert @user.save
+ end
+
+ test "validating with `validates()`, Array shortcut option" do
+ User.validates(:avatar, attachment_content_type: @bad_content_types)
+ User.validates(:highlights, attachment_content_type: @bad_content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_content_type: @content_types)
+ User.validates(:highlights, attachment_content_type: @content_types)
+
+ assert @user.save
+ end
+
+ test "validating with `validates()`, invalid shortcut option" do
+ User.validates(:avatar, attachment_content_type: @bad_content_types.first)
+ User.validates(:highlights, attachment_content_type: @bad_content_types.first)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates(:avatar, attachment_content_type: @content_types.first)
+ User.validates(:highlights, attachment_content_type: @content_types.first)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`" do
+ User.validates_attachment(:avatar, content_type: { in: @bad_content_types })
+ User.validates_attachment(:highlights, content_type: { in: @bad_content_types })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment(:avatar, content_type: { in: @content_types })
+ User.validates_attachment(:highlights, content_type: { in: @content_types })
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`, String shortcut option" do
+ User.validates_attachment(:avatar, content_type: @bad_content_types.first)
+ User.validates_attachment(:highlights, content_type: @bad_content_types.first)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment(:avatar, content_type: @content_types.first)
+ User.validates_attachment(:highlights, content_type: @content_types.first)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`, Array shortcut option" do
+ User.validates_attachment(:avatar, content_type: @bad_content_types)
+ User.validates_attachment(:highlights, content_type: @bad_content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment(:avatar, content_type: @content_types)
+ User.validates_attachment(:highlights, content_type: @content_types)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment_content_type()`" do
+ User.validates_attachment_content_type(:avatar, in: @bad_content_types)
+ User.validates_attachment_content_type(:highlights, in: @bad_content_types)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal ["is not included in the list"], @user.errors.messages[:avatar]
+ assert_equal ["is not included in the list"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ User.validates_attachment_content_type(:avatar, in: @content_types)
+ User.validates_attachment_content_type(:highlights, in: @content_types)
+
+ assert @user.save
+ end
+
+ test "specifying a :message option" do
+ message = "Content Type not valid for %{model}#%{attribute}"
+
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types, message: message)
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types, message: message)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert_not @user.valid?
+ assert_equal(
+ ["Content Type not valid for User#Avatar"],
+ @user.errors.messages[:avatar]
+ )
+ assert_equal(
+ ["Content Type not valid for User#Highlights"],
+ @user.errors.messages[:highlights]
+ )
+ end
+
+ test "passing a proc to :with option" do
+ User.validates_with(VALIDATOR, attributes: :avatar, with: Proc.new { |user| user.persisted? ? @content_types : @bad_content_types })
+ User.validates_with(VALIDATOR, attributes: :highlights, with: Proc.new { |user| user.persisted? ? @content_types : @bad_content_types })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+
+ new_user = User.new
+ new_user.avatar.attach(@blob)
+ new_user.highlights.attach(@blob)
+
+ assert_not new_user.valid?
+ assert_equal(
+ ["is not included in the list"],
+ new_user.errors.messages[:avatar]
+ )
+ assert_equal(
+ ["is not included in the list"],
+ new_user.errors.messages[:highlights]
+ )
+ end
+
+ test "inheritance of default ActiveModel options" do
+ User.validates_with(VALIDATOR, attributes: :avatar, in: @bad_content_types, if: Proc.new { false })
+ User.validates_with(VALIDATOR, attributes: :highlights, in: @bad_content_types, if: Proc.new { false })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+ end
+end
diff --git a/activestorage/test/models/validations/attachment_precense_validator_test.rb b/activestorage/test/models/validations/attachment_precense_validator_test.rb
new file mode 100644
index 0000000000000..d6b8634174fb5
--- /dev/null
+++ b/activestorage/test/models/validations/attachment_precense_validator_test.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "database/setup"
+
+class ActiveStorage::AttachmentPresenceValidatorTest < ActiveSupport::TestCase
+ VALIDATOR = ActiveStorage::Validations::AttachmentPresenceValidator
+
+ setup do
+ @old_validators = User._validators.deep_dup
+ @old_callbacks = User._validate_callbacks.deep_dup
+
+ @blob = create_blob
+ @user = User.create(name: "Anjali")
+ end
+
+ teardown do
+ User.destroy_all
+ ActiveStorage::Blob.all.each(&:purge)
+
+ User.clear_validators!
+ # NOTE: `clear_validators!` clears both registered validators and any
+ # callbacks registered by `validate()`, so ensure that both are restored
+ User._validators = @old_validators if @old_validators
+ User._validate_callbacks = @old_callbacks if @old_callbacks
+ end
+
+ test "record has no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ assert_not @user.save
+ end
+
+ test "new record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user = User.new(name: "Rohini")
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+ end
+
+ test "persisted record, creating attachments" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating attachments" do
+ other_blob = create_blob
+ @user.avatar.attach(other_blob)
+ @user.highlights.attach(other_blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.valid?
+
+ User.clear_validators!
+
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ assert @user.save
+ end
+
+ test "persisted record, updating some other field" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user.name = "Rohini"
+
+ assert @user.save
+ end
+
+ test "persisted record, destroying attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert_not @user.save
+ end
+
+ test "destroying record with attachments" do
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user.avatar.detach
+ @user.highlights.detach
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "new record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ @user = User.new(name: "Rohini")
+
+ assert_not @user.save
+ end
+
+ test "persisted record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ assert_not @user.save
+ end
+
+ test "destroying record, with no attachment" do
+ User.validates_with(VALIDATOR, attributes: :avatar)
+ User.validates_with(VALIDATOR, attributes: :highlights)
+
+ assert @user.destroy
+ assert_not @user.persisted?
+ end
+
+ test "validating with `validates()`" do
+ User.validates(:avatar, attachment_presence: true)
+ User.validates(:highlights, attachment_presence: true)
+
+ assert_not @user.valid?
+ assert_equal ["can't be blank"], @user.errors.messages[:avatar]
+ assert_equal ["can't be blank"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates(:avatar, attachment_presence: true)
+ User.validates(:highlights, attachment_presence: true)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment()`" do
+ User.validates_attachment(:avatar, presence: true)
+ User.validates_attachment(:highlights, presence: true)
+
+ assert_not @user.valid?
+
+ User.clear_validators!
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_attachment(:avatar, presence: true)
+ User.validates_attachment(:highlights, presence: true)
+
+ assert @user.save
+ end
+
+ test "validating with `validates_attachment_presence()`" do
+ User.validates_attachment_presence(:avatar)
+ User.validates_attachment_presence(:highlights)
+
+ assert_not @user.valid?
+ assert_equal ["can't be blank"], @user.errors.messages[:avatar]
+ assert_equal ["can't be blank"], @user.errors.messages[:highlights]
+
+ User.clear_validators!
+
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ User.validates_attachment_presence(:avatar)
+ User.validates_attachment_presence(:highlights)
+
+ assert @user.save
+ end
+
+ test "specifying a :message option" do
+ message = "Validating %{model}#%{attribute}. The %{attribute} can't be blank"
+
+ User.validates_with(VALIDATOR, attributes: :avatar, message: message)
+ User.validates_with(VALIDATOR, attributes: :highlights, message: message)
+
+ assert_not @user.valid?
+ assert_equal(
+ ["Validating User#Avatar. The Avatar can't be blank"],
+ @user.errors.messages[:avatar]
+ )
+ assert_equal(
+ ["Validating User#Highlights. The Highlights can't be blank"],
+ @user.errors.messages[:highlights]
+ )
+ end
+
+ test "inheritance of default ActiveModel options" do
+ User.validates_with(VALIDATOR, attributes: :avatar, presence: true, if: Proc.new { false })
+ User.validates_with(VALIDATOR, attributes: :highlights, presence: true, if: Proc.new { false })
+
+ @user.avatar.attach(@blob)
+ @user.highlights.attach(@blob)
+
+ assert @user.save
+ end
+end
diff --git a/activestorage/test/template/file_field_tag_test.rb b/activestorage/test/template/file_field_tag_test.rb
new file mode 100644
index 0000000000000..e7e786ab912b9
--- /dev/null
+++ b/activestorage/test/template/file_field_tag_test.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "database/setup"
+class ActiveStorage::FileFieldTagTest < ActionView::TestCase
+ tests ActionView::Helpers::FormTagHelper
+
+ test "file_field_tag has access to rails_direct_uploads_signed_validation_id" do
+ expected = ""
+ assert_dom_equal expected, file_field_tag("avatar", direct_upload: true)
+
+ expected = ""
+ assert_dom_equal expected, file_field_tag("avatar", direct_upload: true, data: { direct_upload_signed_validation_id: User.new.avatar.to_signed_validation_id })
+ end
+
+ test "file_field has access to rails_direct_uploads_signed_validation_id" do
+ expected = ""
+ assert_dom_equal expected, file_field("user", "avatar", direct_upload: true)
+
+ expected = ""
+ assert_dom_equal expected, file_field("user", "avatar", direct_upload: true, data: { direct_upload_signed_validation_id: User.new.avatar.to_signed_validation_id })
+ end
+
+ Routes = ActionDispatch::Routing::RouteSet.new
+ Routes.draw do
+ resources :users
+ end
+ include Routes.url_helpers
+
+ def form_for(*)
+ @output_buffer = super
+ end
+
+ test "form builder includes direct upload attributes" do
+ @user = User.create!(name: "DHH")
+
+ form_for(@user) do |f|
+ concat f.file_field(:avatar, direct_upload: true)
+ end
+
+ expected = %Q()
+
+ assert_includes output_buffer, expected
+ end
+end
diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md
index 590fba91069c7..e83e535a750c1 100644
--- a/guides/source/active_storage_overview.md
+++ b/guides/source/active_storage_overview.md
@@ -571,6 +571,56 @@ user.avatar.purge_later
[Attached::One#purge]: https://api.rubyonrails.org/classes/ActiveStorage/Attached/One.html#method-i-purge
[Attached::One#purge_later]: https://api.rubyonrails.org/classes/ActiveStorage/Attached/One.html#method-i-purge_later
+Validating Files
+----------------
+
+Active Storage includes attachment validators for the following properties:
+
+* Byte Size
+* Content Type
+
+### Size
+
+Validates the size (in bytes) of the attached `Blob` object:
+
+ ```ruby
+ validates :avatar, attachment_byte_size: { in: 0..1.megabyte }
+ validates :avatar, attachment_byte_size: { minimum: 17.kilobytes }
+ validates :avatar, attachment_byte_size: { maximum: 38.megabytes }
+ ```
+
+Also accepts a `Range` as a shortcut option for `:in`:
+
+ ```ruby
+ validates :avatar, attachment_size: 0..1.megabyte
+ ```
+
+### Content Type
+
+Validates the content type of the attached `Blob` object:
+
+ ```ruby
+ validates :avatar, attachment_content_type: { in: %w[image/jpeg image/png] }
+ validates :avatar, attachment_content_type: { not: %w[application/pdf] }
+ ```
+
+Also accepts a `Array` or `String` as a shortcut option for `:in`:
+
+ ```ruby
+ validates :avatar, attachment_content_type: %w[image/jpeg image/png]
+ validates :avatar, attachment_content_type: "image/jpeg"
+ ```
+
+### Validation Helper
+
+Active Storage also provides a more readable validation helper named
+`validates_attachment()` which provides the same functionality as `validates()`
+but does not require the `attachment_` prefix on keys:
+
+ ```ruby
+ validates_attachment :avatar, byte_size: { in: 0..1.megabyte }, content_type: "image/jpeg"
+ ```
+
Serving Files
-------------
@@ -935,6 +985,31 @@ directly from the client to the cloud.
```
+ `FormBuilder` will ensure any defined ActiveStorage validations get run for
+ direct uploads, however if you're not using `FormBuilder` and you want to
+ apply validations to direct uploads you will need to pass in the signed
+ validation id for the ActiveStorage enabled attribute. This ensures that
+ the correct validations are run for the direct upload.
+
+ ```erb
+
+ ```
+
+ Will apply any defined ActiveStorage validations on the `User` model's
+ `avatar` ActiveStorage attribute such as byte_size and content_type
+ validations. For example:
+ ```ruby
+ validates :avatar, attachment_byte_size: { in: 0..1.megabyte }
+ validates :avatar, attachment_content_type: { in: %w[image/jpeg image/png] }
+ ```
+
+ The direct upload will not proceed if the file's *reported* byte_size or
+ content_type do not pass validation. Note that ActiveStorage validations for
+ direct uploads are checking the byte_size and content_type information
+ provided by the client. They are not verifying the *actual* byte_size and
+ content_type of the file. That is beyond the scope of ActiveStorage
+ validations for direct uploads.
+
3. Configure CORS on third-party storage services to allow direct upload requests.
4. That's it! Uploads begin upon form submission.