{"title":"File Validation","description":"Validating file uploads for size, type, and dimensions","section":"guides/uploads","version":"v2","path":"guides/uploads/validation","canonical_url":"https://amberframework.org/docs/v2/guides/uploads/validation","markdown_url":"https://amberframework.org/docs/v2/guides/uploads/validation.md","inherited":false,"content_markdown":"# File Validation\n\n> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.5\n> core web-app release gate. Its package version, API, and platform support may\n> change independently. Confirm a compatible official release before adding it\n> to an application.\n\n## Where the examples go\n\nAttachment validation declarations, conditions, and custom validator methods\nbelong in the matching Grant model under `src/models/`. Analyzer and plugin\nconfiguration belongs in `config/uploads.cr`. Error rendering belongs in\nthe matching ECR file under `src/views/`. Virus scanning and expensive file\ninspection belong in a dedicated job or service after inexpensive limits have\nrun.\n\nGemma provides validation helpers for Grant models to ensure uploaded files meet your requirements.\n\n## Setup\n\nInclude the `AttachmentValidators` module alongside `Attachable`:\n\n```crystal\nrequire \"gemma/grant\"\n\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column id : Int64, primary: true\n  column avatar_data : JSON::Any?\n\n  has_one_attached :avatar\n\n  # Add validations\n  validate_file_size_of :avatar, maximum: 5.megabytes\n  validate_content_type_of :avatar, accept: [\"image/jpeg\", \"image/png\", \"image/gif\"]\nend\n```\n\n## File Size Validation\n\nLimit the size of uploaded files:\n\n```crystal\n# Maximum size only\nvalidate_file_size_of :avatar, maximum: 5.megabytes\n\n# Minimum size only\nvalidate_file_size_of :document, minimum: 1.kilobyte\n\n# Both minimum and maximum\nvalidate_file_size_of :video, minimum: 100.kilobytes, maximum: 100.megabytes\n\n# Custom error message\nvalidate_file_size_of :avatar,\n  maximum: 2.megabytes,\n  message: \"must be smaller than 2MB\"\n```\n\n### Size Helpers\n\nCrystal provides convenient size methods:\n\n```crystal\n1.kilobyte   # 1024 bytes\n1.megabyte   # 1024 * 1024 bytes\n1.gigabyte   # 1024 * 1024 * 1024 bytes\n\n# Or use raw bytes\nvalidate_file_size_of :avatar, maximum: 5_242_880  # 5MB in bytes\n```\n\n## Content Type Validation\n\nRestrict allowed file types:\n\n### Accept List\n\n```crystal\n# Single type\nvalidate_content_type_of :avatar, accept: \"image/jpeg\"\n\n# Multiple types\nvalidate_content_type_of :avatar, accept: [\"image/jpeg\", \"image/png\", \"image/gif\"]\n\n# Wildcard matching\nvalidate_content_type_of :document, accept: [\"application/pdf\", \"image/*\"]\n```\n\n### Reject List\n\n```crystal\n# Block specific types\nvalidate_content_type_of :upload, reject: [\"application/x-executable\", \"application/x-msdownload\"]\n\n# Block category with wildcard\nvalidate_content_type_of :document, reject: \"video/*\"\n```\n\n### Custom Message\n\n```crystal\nvalidate_content_type_of :avatar,\n  accept: [\"image/jpeg\", \"image/png\"],\n  message: \"must be a JPEG or PNG image\"\n```\n\n### Common Content Types\n\n| Category | Types |\n|----------|-------|\n| Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml` |\n| Documents | `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.*` |\n| Video | `video/mp4`, `video/webm`, `video/quicktime` |\n| Audio | `audio/mpeg`, `audio/wav`, `audio/ogg` |\n| Archives | `application/zip`, `application/x-tar`, `application/gzip` |\n\n## Presence Validation\n\nRequire an attachment to be present:\n\n```crystal\nclass Profile < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column photo_data : JSON::Any?\n  has_one_attached :photo\n\n  # Photo is required\n  validate_presence_of :photo\n\n  # Custom message\n  validate_presence_of :photo, message: \"Please upload a profile photo\"\nend\n```\n\n## Dimension Validation\n\nValidate image dimensions (requires StoreDimensions plugin):\n\n```crystal\nrequire \"fastimage\"\nrequire \"gemma/plugins/store_dimensions\"\n\nclass ImageUploader < Gemma\n  load_plugin(\n    Gemma::Plugins::StoreDimensions,\n    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage\n  )\n  finalize_plugins!\nend\n\nclass Photo < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column image_data : JSON::Any?\n  has_one_attached :image, uploader: ImageUploader\n\n  # Exact dimensions\n  validate_dimensions_of :image, width: 800, height: 600\n\n  # Range of dimensions\n  validate_dimensions_of :image,\n    width: 100..2000,\n    height: 100..2000\n\n  # Only width constraint\n  validate_dimensions_of :image, width: 800..1920\n\n  # Only height constraint\n  validate_dimensions_of :image, height: 600..1080\nend\n```\n\n## Collection Size Validation\n\nFor `has_many_attached`, validate the number of files:\n\n```crystal\nclass Post < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column images_data : JSON::Any?\n  has_many_attached :images\n\n  # Require at least one image\n  validate_collection_size_of :images, minimum: 1\n\n  # Maximum 10 images\n  validate_collection_size_of :images, maximum: 10\n\n  # Between 1 and 5 images\n  validate_collection_size_of :images, minimum: 1, maximum: 5\n\n  # Custom message\n  validate_collection_size_of :images,\n    maximum: 5,\n    message: \"You can upload at most 5 images\"\nend\n```\n\n## Combining Validations\n\nApply multiple validations to the same attachment:\n\n```crystal\nclass Document < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column file_data : JSON::Any?\n  has_one_attached :file\n\n  # Must be present\n  validate_presence_of :file\n\n  # Size between 1KB and 10MB\n  validate_file_size_of :file,\n    minimum: 1.kilobyte,\n    maximum: 10.megabytes\n\n  # Must be PDF or Word document\n  validate_content_type_of :file,\n    accept: [\n      \"application/pdf\",\n      \"application/msword\",\n      \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n    ]\nend\n```\n\n## Conditional Validation\n\nUse standard Grant validation conditions:\n\n```crystal\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column avatar_data : JSON::Any?\n  column is_premium : Bool = false\n\n  has_one_attached :avatar\n\n  # Premium users can upload larger avatars\n  validate :avatar_size_for_user_type\n\n  private def avatar_size_for_user_type\n    return unless avatar\n\n    max_size = is_premium ? 10.megabytes : 2.megabytes\n\n    if (size = avatar.size) && size > max_size\n      errors.add(:avatar, \"is too large for your account type\")\n    end\n  end\nend\n```\n\n## Custom Validators\n\nCreate custom validation logic:\n\n```crystal\nclass Photo < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column image_data : JSON::Any?\n  has_one_attached :image\n\n  validate :image_aspect_ratio\n\n  private def image_aspect_ratio\n    return unless image\n\n    width = image.metadata[\"width\"]?.try(&.to_i)\n    height = image.metadata[\"height\"]?.try(&.to_i)\n\n    return unless width && height\n\n    ratio = width.to_f / height.to_f\n\n    # Require 16:9 aspect ratio (with tolerance)\n    unless (1.7..1.8).includes?(ratio)\n      errors.add(:image, \"must have a 16:9 aspect ratio\")\n    end\n  end\nend\n```\n\n### Virus Scanning\n\n```crystal\nclass Upload < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column file_data : JSON::Any?\n  has_one_attached :file\n\n  validate :scan_for_viruses\n\n  private def scan_for_viruses\n    return unless file && file_changed?\n\n    file.download do |tempfile|\n      result = `clamscan --no-summary #{tempfile.path}`\n      status = $?.exit_code\n\n      if status != 0\n        errors.add(:file, \"failed virus scan\")\n      end\n    end\n  end\nend\n```\n\n## Error Messages\n\nAccess validation errors:\n\n```crystal\nuser = User.new(name: \"Alice\")\nuser.avatar = large_file\n\nunless user.valid?\n  user.errors[:avatar].each do |error|\n    puts error  # => \"is too large (maximum is 5242880 bytes)\"\n  end\nend\n```\n\n### Display in Views\n\n```ecr\n<% if @user.errors[:avatar].any? %>\n  <div class=\"alert alert-danger\">\n    <% @user.errors[:avatar].each do |error| %>\n      <p>Avatar <%= error %></p>\n    <% end %>\n  </div>\n<% end %>\n```\n\n## MIME Type Detection\n\nFor accurate content type validation, use the DetermineMimeType plugin:\n\n```crystal\nrequire \"gemma/plugins/determine_mime_type\"\n\nclass SecureUploader < Gemma\n  load_plugin(\n    Gemma::Plugins::DetermineMimeType,\n    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File\n  )\n  finalize_plugins!\nend\n\nclass Document < Grant::Base\n  include Gemma::Grant::Attachable\n  include Gemma::Grant::AttachmentValidators\n\n  column file_data : JSON::Any?\n  has_one_attached :file, uploader: SecureUploader\n\n  # Now validates against actual file content, not just extension\n  validate_content_type_of :file, accept: \"application/pdf\"\nend\n```\n\n### Analyzer Options\n\n| Analyzer | Description |\n|----------|-------------|\n| `File` | Uses system `file` command (most accurate) |\n| `Mime` | Uses Crystal's `MIME.from_filename` |\n| `ContentType` | Uses HTTP Content-Type header (least secure) |\n\n## Best Practices\n\n### 1. Always Validate Content Type\n\nDon't trust file extensions alone:\n\n```crystal\n# Use File analyzer for security\nload_plugin(\n  Gemma::Plugins::DetermineMimeType,\n  analyzer: Gemma::Plugins::DetermineMimeType::Tools::File\n)\n\nvalidate_content_type_of :upload, accept: [...]\n```\n\n### 2. Set Reasonable Size Limits\n\nPrevent resource exhaustion:\n\n```crystal\n# Avatars: 2-5 MB\nvalidate_file_size_of :avatar, maximum: 5.megabytes\n\n# Documents: 10-50 MB\nvalidate_file_size_of :document, maximum: 50.megabytes\n\n# Videos: Set based on your infrastructure\nvalidate_file_size_of :video, maximum: 500.megabytes\n```\n\n### 3. Validate Before Processing\n\nCheck files before expensive operations:\n\n```crystal\nclass Video < Grant::Base\n  validate_content_type_of :file, accept: \"video/*\"\n  validate_file_size_of :file, maximum: 500.megabytes\n\n  after_save :transcode_video\n\n  private def transcode_video\n    # Only runs if validations pass\n    # Safe to process the file\n  end\nend\n```\n\n### 4. Provide Helpful Error Messages\n\nGuide users to fix issues:\n\n```crystal\nvalidate_file_size_of :avatar,\n  maximum: 5.megabytes,\n  message: \"must be smaller than 5MB. Try compressing your image.\"\n\nvalidate_content_type_of :avatar,\n  accept: [\"image/jpeg\", \"image/png\"],\n  message: \"must be a JPEG or PNG file. Other formats are not supported.\"\n```"}