{"title":"File Uploads (Gemma)","description":"File attachment toolkit for Crystal applications","section":"guides","version":"v2","path":"guides/uploads","canonical_url":"https://amberframework.org/docs/v2/guides/uploads","markdown_url":"https://amberframework.org/docs/v2/guides/uploads.md","inherited":false,"content_markdown":"# File Uploads with Gemma\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\nGemma is a file attachment toolkit for Crystal applications, inspired by [Shrine for Ruby](https://shrinerb.com). It connects model attachments to validation, temporary uploads, permanent storage, and delivery across configurable backends.\n\n## Authored assets and uploads are different lifecycles\n\nUse [Asset Pipeline](../assets/) for files that ship with an application release:\nCSS, JavaScript, logos, interface images, fonts, icons, and other reviewed static\nfiles. Those files can be content-addressed, included in a release manifest, and\ncached immutably because the deployment owns their bytes.\n\nUse Gemma for files received while the application is running. An upload is\nuntrusted input and may be private, replaced, or deleted. Do not copy uploads\ninto the Asset Pipeline source tree, add them to its manifest, or assume its\nimmutable cache policy applies. Validate the file, store it outside the\napplication release artifact, and choose an authenticated controller response,\na presigned object-storage URL, or an explicitly configured public-upload route\nfor delivery.\n\n## Where the examples go\n\n- Add dependencies in `shard.yml` and run commands from the application root.\n- Configure Gemma in `config/uploads.cr`. The generated application entry point\n  loads top-level `config/*` files before application source.\n- Attachment declarations belong in Grant models under `src/models/`.\n- Upload handling belongs in the receiving controller under `src/controllers/`;\n  form and display markup belongs in the matching ECR file under `src/views/`.\n\nBlocks on this page use those destinations unless a closer label says\notherwise.\n\n## Why Gemma?\n\n- **Storage Agnostic** - Switch between filesystem and S3 without changing application code\n- **Grant Integration** - First-class support for Grant ORM with `has_one_attached` and `has_many_attached`\n- **Validation Support** - Built-in validators for file size, content type, and dimensions\n- **Plugin System** - Add MIME type detection and metadata extraction\n- **Two-Stage Uploads** - Cache files temporarily, then promote to permanent storage\n\n## Installation\n\n**File: `shard.yml` — add Gemma under the existing `dependencies:` key.**\n\n```yaml\ndependencies:\n  gemma:\n    github: amberframework/gemma\n    version: ~> 0.6.5\n```\n\nRun `shards install` from the application root.\n\n## Quick Start\n\n### 1. Configure Storage\n\n**File: `config/uploads.cr` — create this complete storage configuration. Do not\nput it in the generated empty `config/initializers/` directory unless you also\nadd and verify an explicit require.**\n\n```crystal\nrequire \"gemma\"\n\nGemma.configure do |config|\n  # Temporary storage for uploads in progress\n  config.storages[\"cache\"] = Gemma::Storage::FileSystem.new(\n    \"uploads\",\n    prefix: \"cache\"\n  )\n\n  # Permanent storage for completed uploads\n  config.storages[\"store\"] = Gemma::Storage::FileSystem.new(\"uploads\")\nend\n```\n\n**File: the application entry point, for example `src/my_app.cr` — retain\n`require \"../config/*\"` before controllers and models.** A migrated app with a\nnarrower require list must explicitly require `../config/uploads`; creating the\nfile alone does not load it.\n\nThis example stores files under project-root `uploads/`. Amber's generated\nstatic route serves `public/`; it does **not** make project-root `uploads/`\npublic. Keep private uploads there and deliver them through an authorized\napplication endpoint or object storage. If the product deliberately uses public\nlocal uploads, configure a dedicated persistent directory and route, and test\nthe returned Gemma URL before rendering it in a view.\n\n### 2. Add Attachment to Model\n\n**File: `src/models/user.cr` — keep the attachment declaration inside `User`.**\n\n```crystal\nrequire \"gemma/grant\"\n\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column id : Int64, primary: true\n  column name : String\n  column avatar_data : JSON::Any?\n\n  has_one_attached :avatar\nend\n```\n\n### 3. Use in Controller\n\n**File: `src/controllers/users_controller.cr` — add this behavior inside the\naction that receives the upload.**\n\n```crystal\nclass UsersController < ApplicationController\n  def create\n    user = User.new(user_params)\n\n    # Assign uploaded file\n    if file = params.files[\"avatar\"]?\n      user.avatar = file.file\n    end\n\n    if user.save\n      redirect_to \"/users/#{user.id}\"\n    else\n      render \"users/new.ecr\"\n    end\n  end\nend\n```\n\n### 4. Display in View\n\n**File: `src/views/users/show.ecr` — render the attachment inside the user page.**\n\n```ecr\n<% if user.avatar %>\n  <img src=\"<%= user.avatar_url %>\" alt=\"Avatar\">\n<% end %>\n```\n\nThis view assumes `avatar_url` resolves through the delivery path selected\nabove. Request that URL directly during verification; a URL-shaped value alone\ndoes not prove that Amber can serve the stored file.\n\n## How It Works\n\nGemma uses a two-stage upload process:\n\n1. **Cache Stage** - Files are first uploaded to temporary \"cache\" storage\n2. **Store Stage** - On model save, cached files are promoted to permanent \"store\" storage\n\nThis approach provides several benefits:\n\n- Failed validations don't leave orphaned files\n- Users can preview uploads before final submission\n- Background processing can happen between stages\n\n```crystal\n# Behind the scenes\nuser.avatar = uploaded_file  # Uploaded to cache\nuser.save                     # Promoted to store\n```\n\n## Core Concepts\n\n### UploadedFile\n\nRepresents an uploaded file with metadata:\n\n```crystal\nuploaded_file = user.avatar\n\nuploaded_file.id              # => \"abc123.jpg\"\nuploaded_file.url             # => \"/uploads/abc123.jpg\"\nuploaded_file.size            # => 12345\nuploaded_file.mime_type       # => \"image/jpeg\"\nuploaded_file.original_filename # => \"photo.jpg\"\nuploaded_file.extension       # => \"jpg\"\nuploaded_file.exists?         # => true\n\n# Access raw IO\nuploaded_file.open do |io|\n  # Process file content\nend\n\n# Download to tempfile\nuploaded_file.download do |tempfile|\n  # Work with local file\nend\n```\n\n### Storages\n\nGemma supports multiple storage backends:\n\n| Storage | Use Case |\n|---------|----------|\n| `FileSystem` | Local development, simple deployments |\n| `S3` | Production, cloud deployments |\n| `Memory` | Testing |\n\n### Attacher\n\nThe internal mechanism that manages file attachment lifecycle:\n\n```crystal\nattacher = user._avatar_attacher\n\nattacher.file       # Current file\nattacher.cached?    # File in temporary storage?\nattacher.stored?    # File in permanent storage?\nattacher.changed?   # File was modified?\nattacher.url        # File URL\n```\n\n## Features\n\n### Single File Attachments\n\n```crystal\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column avatar_data : JSON::Any?\n  has_one_attached :avatar\nend\n\nuser.avatar = File.open(\"photo.jpg\")\nuser.save\n\nuser.avatar_url  # => \"/uploads/abc123.jpg\"\n```\n\n### Multiple File Attachments\n\n```crystal\nclass Post < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column images_data : JSON::Any?\n  has_many_attached :images\nend\n\npost.images = [File.open(\"img1.jpg\"), File.open(\"img2.jpg\")]\npost.save\n\npost.images.each do |image|\n  puts image.url\nend\n\n# Add single file\npost.add_image(File.open(\"img3.jpg\"))\n\n# Remove file\npost.remove_image(post.images.first)\n\n# Clear all\npost.clear_images\n```\n\n### Custom Uploaders\n\nCreate custom uploaders for specialized handling:\n\n```crystal\nclass ImageUploader < Gemma\n  def generate_location(io, metadata, context, **options)\n    name = super(io, metadata, **options)\n\n    # Organize by model and ID\n    File.join(\n      context[:model].class.name.underscore,\n      context[:model].id.to_s,\n      name\n    )\n  end\nend\n\n# Use custom uploader\nhas_one_attached :avatar, uploader: ImageUploader\n```\n\n## Next Steps\n\n- [Attachments](attachments/) - Single and multiple file attachments\n- [Storage Backends](storage/) - Configure FileSystem and S3\n- [Validation](validation/) - Validate file uploads"}