Documentation

File Validation

New
Browse documentation

Published 2026-08-13. V2 is a prerelease; the web core is release-gated and other previews are named separately. What beta means.

Read this page as HTML, Markdown, or structured JSON—or open the published Markdown with an AI assistant. Gemini receives the prompt through your clipboard because its signed-out page does not reliably prefill URL text; paste when the new tab opens. External assistants need the public site URL.

1.4.1
Unavailable in this version
1.5
Unavailable in this version

File Validation

Preview ecosystem guide: Gemma is not part of the Amber 2.0.0-beta.5 core web-app release gate. Its package version, API, and platform support may change independently. Confirm a compatible official release before adding it to an application.

Where the examples go

Attachment validation declarations, conditions, and custom validator methods belong in the matching Grant model under src/models/. Analyzer and plugin configuration belongs in config/uploads.cr. Error rendering belongs in the matching ECR file under src/views/. Virus scanning and expensive file inspection belong in a dedicated job or service after inexpensive limits have run.

Gemma provides validation helpers for Grant models to ensure uploaded files meet your requirements.

Setup

Include the AttachmentValidators module alongside Attachable:

Crystal
require "gemma/grant"

class User < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column id : Int64, primary: true
  column avatar_data : JSON::Any?

  has_one_attached :avatar

  # Add validations
  validate_file_size_of :avatar, maximum: 5.megabytes
  validate_content_type_of :avatar, accept: ["image/jpeg", "image/png", "image/gif"]
end

File Size Validation

Limit the size of uploaded files:

Crystal
# Maximum size only
validate_file_size_of :avatar, maximum: 5.megabytes

# Minimum size only
validate_file_size_of :document, minimum: 1.kilobyte

# Both minimum and maximum
validate_file_size_of :video, minimum: 100.kilobytes, maximum: 100.megabytes

# Custom error message
validate_file_size_of :avatar,
  maximum: 2.megabytes,
  message: "must be smaller than 2MB"

Size Helpers

Crystal provides convenient size methods:

Crystal
1.kilobyte   # 1024 bytes
1.megabyte   # 1024 * 1024 bytes
1.gigabyte   # 1024 * 1024 * 1024 bytes

# Or use raw bytes
validate_file_size_of :avatar, maximum: 5_242_880  # 5MB in bytes

Content Type Validation

Restrict allowed file types:

Accept List

Crystal
# Single type
validate_content_type_of :avatar, accept: "image/jpeg"

# Multiple types
validate_content_type_of :avatar, accept: ["image/jpeg", "image/png", "image/gif"]

# Wildcard matching
validate_content_type_of :document, accept: ["application/pdf", "image/*"]

Reject List

Crystal
# Block specific types
validate_content_type_of :upload, reject: ["application/x-executable", "application/x-msdownload"]

# Block category with wildcard
validate_content_type_of :document, reject: "video/*"

Custom Message

Crystal
validate_content_type_of :avatar,
  accept: ["image/jpeg", "image/png"],
  message: "must be a JPEG or PNG image"

Common Content Types

Category Types
Images image/jpeg, image/png, image/gif, image/webp, image/svg+xml
Documents application/pdf, application/msword, application/vnd.openxmlformats-officedocument.*
Video video/mp4, video/webm, video/quicktime
Audio audio/mpeg, audio/wav, audio/ogg
Archives application/zip, application/x-tar, application/gzip

Presence Validation

Require an attachment to be present:

Crystal
class Profile < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column photo_data : JSON::Any?
  has_one_attached :photo

  # Photo is required
  validate_presence_of :photo

  # Custom message
  validate_presence_of :photo, message: "Please upload a profile photo"
end

Dimension Validation

Validate image dimensions (requires StoreDimensions plugin):

Crystal
require "fastimage"
require "gemma/plugins/store_dimensions"

class ImageUploader < Gemma
  load_plugin(
    Gemma::Plugins::StoreDimensions,
    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage
  )
  finalize_plugins!
end

class Photo < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column image_data : JSON::Any?
  has_one_attached :image, uploader: ImageUploader

  # Exact dimensions
  validate_dimensions_of :image, width: 800, height: 600

  # Range of dimensions
  validate_dimensions_of :image,
    width: 100..2000,
    height: 100..2000

  # Only width constraint
  validate_dimensions_of :image, width: 800..1920

  # Only height constraint
  validate_dimensions_of :image, height: 600..1080
end

Collection Size Validation

For has_many_attached, validate the number of files:

Crystal
class Post < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column images_data : JSON::Any?
  has_many_attached :images

  # Require at least one image
  validate_collection_size_of :images, minimum: 1

  # Maximum 10 images
  validate_collection_size_of :images, maximum: 10

  # Between 1 and 5 images
  validate_collection_size_of :images, minimum: 1, maximum: 5

  # Custom message
  validate_collection_size_of :images,
    maximum: 5,
    message: "You can upload at most 5 images"
end

Combining Validations

Apply multiple validations to the same attachment:

Crystal
class Document < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column file_data : JSON::Any?
  has_one_attached :file

  # Must be present
  validate_presence_of :file

  # Size between 1KB and 10MB
  validate_file_size_of :file,
    minimum: 1.kilobyte,
    maximum: 10.megabytes

  # Must be PDF or Word document
  validate_content_type_of :file,
    accept: [
      "application/pdf",
      "application/msword",
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    ]
end

Conditional Validation

Use standard Grant validation conditions:

Crystal
class User < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column avatar_data : JSON::Any?
  column is_premium : Bool = false

  has_one_attached :avatar

  # Premium users can upload larger avatars
  validate :avatar_size_for_user_type

  private def avatar_size_for_user_type
    return unless avatar

    max_size = is_premium ? 10.megabytes : 2.megabytes

    if (size = avatar.size) && size > max_size
      errors.add(:avatar, "is too large for your account type")
    end
  end
end

Custom Validators

Create custom validation logic:

Crystal
class Photo < Grant::Base
  include Gemma::Grant::Attachable

  column image_data : JSON::Any?
  has_one_attached :image

  validate :image_aspect_ratio

  private def image_aspect_ratio
    return unless image

    width = image.metadata["width"]?.try(&.to_i)
    height = image.metadata["height"]?.try(&.to_i)

    return unless width && height

    ratio = width.to_f / height.to_f

    # Require 16:9 aspect ratio (with tolerance)
    unless (1.7..1.8).includes?(ratio)
      errors.add(:image, "must have a 16:9 aspect ratio")
    end
  end
end

Virus Scanning

Crystal
class Upload < Grant::Base
  include Gemma::Grant::Attachable

  column file_data : JSON::Any?
  has_one_attached :file

  validate :scan_for_viruses

  private def scan_for_viruses
    return unless file && file_changed?

    file.download do |tempfile|
      result = `clamscan --no-summary #{tempfile.path}`
      status = $?.exit_code

      if status != 0
        errors.add(:file, "failed virus scan")
      end
    end
  end
end

Error Messages

Access validation errors:

Crystal
user = User.new(name: "Alice")
user.avatar = large_file

unless user.valid?
  user.errors[:avatar].each do |error|
    puts error  # => "is too large (maximum is 5242880 bytes)"
  end
end

Display in Views

ECR template
<% if @user.errors[:avatar].any? %>
  <div class="alert alert-danger">
    <% @user.errors[:avatar].each do |error| %>
      <p>Avatar <%= error %></p>
    <% end %>
  </div>
<% end %>

MIME Type Detection

For accurate content type validation, use the DetermineMimeType plugin:

Crystal
require "gemma/plugins/determine_mime_type"

class SecureUploader < Gemma
  load_plugin(
    Gemma::Plugins::DetermineMimeType,
    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
  )
  finalize_plugins!
end

class Document < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column file_data : JSON::Any?
  has_one_attached :file, uploader: SecureUploader

  # Now validates against actual file content, not just extension
  validate_content_type_of :file, accept: "application/pdf"
end

Analyzer Options

Analyzer Description
File Uses system file command (most accurate)
Mime Uses Crystal's MIME.from_filename
ContentType Uses HTTP Content-Type header (least secure)

Best Practices

1. Always Validate Content Type

Don't trust file extensions alone:

Crystal
# Use File analyzer for security
load_plugin(
  Gemma::Plugins::DetermineMimeType,
  analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
)

validate_content_type_of :upload, accept: [...]

2. Set Reasonable Size Limits

Prevent resource exhaustion:

Crystal
# Avatars: 2-5 MB
validate_file_size_of :avatar, maximum: 5.megabytes

# Documents: 10-50 MB
validate_file_size_of :document, maximum: 50.megabytes

# Videos: Set based on your infrastructure
validate_file_size_of :video, maximum: 500.megabytes

3. Validate Before Processing

Check files before expensive operations:

Crystal
class Video < Grant::Base
  validate_content_type_of :file, accept: "video/*"
  validate_file_size_of :file, maximum: 500.megabytes

  after_save :transcode_video

  private def transcode_video
    # Only runs if validations pass
    # Safe to process the file
  end
end

4. Provide Helpful Error Messages

Guide users to fix issues:

Crystal
validate_file_size_of :avatar,
  maximum: 5.megabytes,
  message: "must be smaller than 5MB. Try compressing your image."

validate_content_type_of :avatar,
  accept: ["image/jpeg", "image/png"],
  message: "must be a JPEG or PNG file. Other formats are not supported."