Accepted

ADR 0048 — Images in core

A first-party media library backed by a media table, content-addressable R2 storage, and dedup-by-hash — no plugin required

Putting a picture in a post is something an editor expects to do on day one. Cusp handles images in core: a first-party media library backed by a media table and content-addressable R2 storage, with no plugin required for basic image authoring.

The decision

A media table backs an admin media library. The schema, R2 layout, dedup posture, and lifecycle are fixed as follows.

The media table

Created by migrations/0014_media.sql. The columns:

  • id — a ULID, never an auto-increment integer.
  • content_hash — a lowercase hex SHA-256 of the bytes. A unique index on content_hash where deleted_at is null provides dedup-by-hash.
  • filename, mime, size_bytes, width, height — descriptive metadata; dimensions are best-effort and may be null on a probe failure.
  • alt — an empty string means decorative; the column is never null.
  • r2_key — the location of the bytes in R2.
  • uploader_id — a foreign key to users, set to null on user delete.
  • deleted_at — the soft-delete tombstone.
  • created_at and updated_at — Unix epoch timestamps.

Versioned entity

media_metadata is in the canonical versioned-entity registry. The media row holds the live data; every mutation — an alt edit, a filename rename, a soft-delete — goes through the versioned-write helper and accumulates entries in entity_versions. Restore and site-wide time travel work on media rows exactly as they do on other entities.

R2 layout and caching

Bytes live at media/{yyyy}/{mm}/{content_hash}.{ext}. Because the key is content-addressed, the bytes under a given key never change, which makes the immutable cache header on the R2 PUT honest. The year and month prefix bounds directory listing for any operator who later wants to enumerate.

The header on the PUT is: Cache-Control: public, max-age=31536000, immutable.

Upload validation pipeline

Validation runs at the Worker boundary, in order: a 25 MB Content-Length cap (re-checked against the parsed buffer), multipart parse, a MIME sniff from the magic bytes (the allowlist is PNG, JPEG, WebP, GIF, AVIF — SVG, HTML, and JS are rejected even if the Content-Type header lies), SHA-256, a dedup lookup that returns the existing live row when the hash already exists, a best-effort dimensions probe, the R2 PUT, and finally a versioned insert through the media repository.

Why

Cloudflare Image Transformations work directly on R2-backed originals and cost nothing for small sites, so storing originals in core and transforming at the edge adds value without embedding a paid integration in the Worker. The paid Cloudflare Images product stays an optional plugin for sites that outgrow the free transform tier.

Dedup-by-hash is free: two uploads of the same bytes share one R2 object and one library row, so an editor who pastes the same logo across many documents pays the storage cost once.

Consequences

  • An editor on a fresh install can upload, pick, caption, replace, and delete images. The published page serves correctly-sized variants only once images.transform_enabled is turned on (it is off by default); until then it serves the unoptimised original. See ADR 0049.
  • Deletion is soft: it sets deleted_at and leaves the R2 bytes. The SweepMedia workflow reaps rows past the retention window once no reference remains.
  • SVG is blocked at upload. The script-in-SVG cross-site-scripting surface is too easy to forget; re-enabling it would need an explicit accept-the-risk confirmation.
  • The MIME allowlist is image-only. Video and audio uploads return 400; a Cloudflare Stream plugin is the path for video.
  • EXIF, including GPS, is preserved on the original bytes.

See the SweepMedia workflow decision for the full sweep algorithm, retention window configuration, and R2 dedup refcounting.

Read the image transformations decision