---
url: /reference/uploaders.md
---
# Uploaders

File uploads are handled by [Shrine](https://shrinerb.com), with Uppy on the front-end:

* `ImageUploader` — base image uploader
* `RecordImageUploader` — attach an image to any record: `include RecordImageUploader::Attachment(:image)`
* `ModelImageUploader` — model-level image uploader (used by content editor images)
* `AudioUploader` — sounds for `f.sound_field` (MP3, AAC/M4A, Ogg, WAV, WebM, FLAC): `include AudioUploader::Attachment(:audio)` on a record with an `audio_data` jsonb column

## Storage

Configured per environment in the generated Shrine initializer (`config/initializers/shrine.rb`):

| Environment | Storage                                                               |
| ----------- | --------------------------------------------------------------------- |
| production  | S3 (via the [`S3_*` ENV variables](/reference/environment-variables)) |
| development | local filesystem (`public/uploads/`)                                  |
| test        | in-memory                                                             |

Image processing uses libvips, blurhash placeholders are generated for progressive loading, and direct uploads go through the Shrine `upload_endpoint`: `POST /admin/upload/image` (20 MB max) and `POST /admin/upload/audio` (200 MB max), handled by the generated `Admin::UploadsController`. The endpoint only checks the size; each uploader validates the file type when it is attached to a record.

## Images: types and sizes

`ImageUploader` accepts JPEG, PNG and WebP images, up to 20 MB and 8000 × 8000 pixels.

### Image metadata

A photo carries metadata a published file shouldn't: its EXIF (the GPS location where it was taken, the camera's make, model and serial number, the date), XMP and IPTC. `ImageUploader` strips it as a file is uploaded, before it reaches the `cache` storage, so it's never stored nor published. libvips re-encodes the image in its own format (a JPEG or a WebP at quality 90, every frame of an animated WebP): it applies the EXIF orientation first, so a photo taken sideways stays upright, and keeps the color profile (ICC) with libvips 8.15 or later; an older libvips strips it too, and the image is shown as sRGB.

A file promoted from the cache to the store isn't processed again.

Images uploaded before Trek stripped this metadata still carry it (their [sizes](#sizes) don't). To strip it, once the app's `ImageUploader` strips it on upload:

```bash
DRY_RUN=1 bin/rails trek:attachments:strip_image_metadata # only lists them
bin/rails trek:attachments:strip_image_metadata
```

The task goes through the files of the `store` storage (S3, the file system, or in memory): the JPEG, PNG and WebP images, and the sizes under `derivatives/`. It re-encodes each one carrying metadata as `ImageUploader` does on upload, and replaces it in place, under the same id and with the same `Content-Type` and `Content-Disposition`: every record, page version and content editor image block pointing to it keeps working, and so do the files no record points to anymore, which are still published. Each line gives the status (`stripped`, `strippable` on a dry run, `failed` when libvips can't read the file) and the file id. The records keep the file's former size in their metadata. Re-encoding can't be undone: enable the bucket's versioning, or copy it, first.

### Sizes

Thumbnails and other sizes come from Shrine's [derivation endpoint](https://shrinerb.com/docs/plugins/derivation_endpoint), mounted at `/derivations/image`: `Trek::ThumbnailHelper.thumbnail_url(file, width, height)`, or `file.derivation_url(:thumbnail, width, height)`, gives the URL of the image resized to fit within `width` × `height`. A size is made on its first request, then stored under `derivatives/` and served from there. The sizes of an image are deleted with it, when Shrine deletes it: its record destroyed, or the image replaced or removed. Every size is a WebP at quality 80, without metadata, whatever the original's type.

The URLs are signed with the `shrine.derivation_endpoint.secret_key` of the credentials, which `trek:install:attachments` writes, or a key derived from `secret_key_base` without it.

## Attachments and records reloaded mid-save

`RecordImageUploader` loads `Trek::SavedAttachmentsReload` on top of Shrine's `activerecord` plugin:

```ruby
class RecordImageUploader < ImageUploader
  plugin :activerecord
  plugin Trek::SavedAttachmentsReload
end
```

Shrine resets every attacher when a record is reloaded, which is right for an explicit reload but loses a pending attachment when something reloads the record *during* the save — closure_tree does exactly that in its `after_save` callback when it reorders the siblings of a numerically ordered tree, as the generated `Page` is. The file would then never be promoted out of the `cache` storage. The plugin keeps, across such a reload, the attachers whose file was saved with the record; an attachment assigned but not saved is still discarded.

## Files left in the cache storage

An uploaded file first lands in the temporary `cache` storage, and is promoted to the permanent `store` after the record is committed. A record whose promotion was skipped keeps pointing to the cache, and its file breaks as soon as the cache is cleaned up. To find those records, for every model and attachment, and promote the ones whose file still exists:

```bash
DRY_RUN=1 bin/rails trek:attachments:promote_cached # only lists them
bin/rails trek:attachments:promote_cached
```

Each line gives the status (`promoted`, `promotable` on a dry run, `missing` when the cached file is gone, `changed` when the record changed meanwhile), the record and attachment, and the file id. Missing files have to be uploaded again.
