Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actionview/lib/action_view/helpers/form_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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+)
Expand Down
12 changes: 10 additions & 2 deletions actionview/lib/action_view/helpers/form_tag_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ def hidden_field_tag(name, value = nil, options = {})
# file_field_tag 'file', accept: 'text/html', class: 'upload', value: 'index.html'
# # => <input accept="text/html" class="upload" id="file" name="file" type="file" 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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions activestorage/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions activestorage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions activestorage/app/assets/javascripts/activestorage.esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -604,19 +605,20 @@ 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) => {
if (error) {
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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 10 additions & 5 deletions activestorage/app/assets/javascripts/activestorage.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions activestorage/app/javascript/activestorage/blob_record.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 3 additions & 2 deletions activestorage/app/javascript/activestorage/direct_upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion activestorage/app/models/active_storage/blob.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|
Expand Down
4 changes: 4 additions & 0 deletions activestorage/lib/active_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions activestorage/lib/active_storage/attached.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions activestorage/lib/active_storage/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

require "active_storage/reflection"

require "active_storage/validations"

module ActiveStorage
class Engine < Rails::Engine # :nodoc:
isolate_namespace ActiveStorage
Expand Down Expand Up @@ -129,6 +131,7 @@ class Engine < Rails::Engine # :nodoc:

ActiveSupport.on_load(:active_record) do
include ActiveStorage::Attached::Model
include ActiveStorage::Validations
end
end

Expand Down
8 changes: 8 additions & 0 deletions activestorage/lib/active_storage/locale/en.yml
Original file line number Diff line number Diff line change
@@ -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}"
Loading