# Gallery

2026-08-08. The seventh application, and the last one before Clubs — which is
built last because a club *contains* galleries, so it would otherwise be built
twice.

Blog and Events proved the shape: registries, `discussions` for comments,
visibility in the service, one level of grouping. What follows is only the parts
where Gallery genuinely differs — and it differs more than Events did, because
Gallery is the first application whose content is **files**.

---

## 🚨 Files are the whole problem

Every other application stores text. A gallery stores things that:

- **Are large**, and arrive over a slow connection from a phone.
- **Are attacker-controlled bytes**, whatever the extension says.
- **Have to be resized**, because a 4000px photo on a listing of forty is
  forty times too much.
- **Outlive their rows**, unless something deliberately removes them.

Each of those has a decision, and each decision is a way to get owned or to
leak somebody's holiday photographs.

### What is accepted

**JPEG, PNG, GIF and WebP, decided by looking at the bytes**, never by the
filename and never by the `Content-Type` the browser claimed. Both are supplied
by whoever is uploading.

🚨 **An SVG is not an image for this purpose.** It is a document that can carry
script, and serving one from the site's own origin is stored cross-site
scripting with a "share your photos" button in front of it. Convoro refuses it
and says why, rather than accepting it and hoping the sanitiser is perfect.

### 🚨 The file is re-encoded, not merely checked

This is the decision that matters most and the one most often skipped.

A file that *parses* as a JPEG can still contain anything in the parts a decoder
skips — the classic being a polyglot that is a valid image and a valid PHP
script at once. Checking the magic bytes proves the first few bytes; it proves
nothing about the rest.

So an upload is decoded into a bitmap and **written out again** from that
bitmap. Whatever was hiding in the original does not survive the round trip,
because the bytes on disk are ones this code produced. It costs CPU on upload
and it is worth every cycle.

It has a second effect worth naming: **EXIF is dropped**, including the GPS
coordinates most phones record. Somebody posting a photo of their garden should
not be publishing their home address, and "strip the metadata" is not a setting
anybody will find.

The one thing deliberately preserved: **orientation**, applied before
re-encoding. Otherwise every phone photo lands sideways.

### Where they go

Under `content/gallery/`, in directories derived from the image id — because
tens of thousands of files in one directory is a directory listing that takes
seconds and a filesystem nobody wants to back up.

🚨 **nginx must be told about the path.** A new upload directory is served from
disk, so it needs its own `location` line; without it every image 404s in
production while working perfectly in development. This has already happened
once with `/brand/`, and it is written here so it happens zero more times.

### Sizes

Two derivatives, made on upload rather than on request: a **thumbnail** for
listings and a **display** copy bounded to something sane. The original is kept
— somebody will want to download it, and re-deriving from a shrunken copy is
not possible.

Made on upload, not on first view: a resize inside a page request is a page
request that takes two seconds the first time each image is seen, which is
exactly when a listing is loading forty of them at once.

## What else it is

Albums, which are the grouping — one level, like everything else. Comments
through `discussions`, the fourth caller. Group restrictions on an album, so
the whole application obeys the standing rule: if someone shouldn't be able to
see it then they don't see it.

## What this note does not decide

- **Video.** A different problem entirely — transcoding, storage, and a
  player — and nobody has asked.
- **Bulk upload of a zip.** Wanted eventually; needs a decision about what
  happens when one file in fifty is refused.
- **Reactions on images.** They exist and they attach to posts; whether an
  image gets them directly or through its comment topic is worth deciding when
  somebody wants it.
