Media Module
Location: apps/api/src/modules/media/
Purpose: Upload pipeline and asset addressing for user images/video/audio. Stores files in Cloudflare R2 (zero-egress); the API only persists the public URL + metadata, never the bytes.
Upload flow (presigned)
POST /media/presign— client asks for an upload target for a planned asset (kind, content-type, size). Returns a short-lived signed PUT URL +assetId+ the eventual public URL. Size/type are validated server-side.- Client
PUTs the bytes directly to R2 (or the dev sink, below). POST /media/:assetId/commit— owner confirms the upload; the asset row is finalized and becomes referenceable by posts/avatars/etc.
| Method | Path | Purpose |
|---|---|---|
POST | /media/presign | Issue a signed upload URL + assetId |
POST | /media/:assetId/commit | Finalize an uploaded asset (owner-only) |
POST | /media/avatar/generate | Generate a DiceBear avatar (no upload) |
PUT | /media/local/:assetId | Dev sink — accept bytes locally |
GET | /media/local/:assetId | Dev sink — serve a local asset |
Avatar generation
POST /media/avatar/generate renders a deterministic DiceBear avatar (lorelei, notionists, personas, micah, avataaars) and stores it like any other asset. Used by the profile-picture picker's "Generate avatar" option.
Dev media sink
When there is no R2 token in development, PUT/GET /media/local/:assetId accept and serve bytes from a local directory so the upload flow works end-to-end offline. Hardened:
- 404 in production — the route is gated to
NODE_ENV !== 'production'. - Token-bound — the upload token's
assetIdmust match the path param. - Path-safe —
assetIdmust be a UUID and isbasename()-ed before joiningDEV_DIR, so a forged token still can't escape the directory.
Mobile consumption — expo-image
The app renders all remote images through expo-image (not RN Image) with disk + memory caching, so avatars and media don't re-fetch or flicker on scroll. This is a native dependency — it bumped the EAS runtimeVersion to 1.4.0 (OTAs must only reach 1.4.0 binaries).
Invariants
- The API never proxies media bytes — only signed URLs and metadata.
- R2 media bucket is public-read; DB backups live in a separate private bucket (see Infrastructure).
See also: /modules/posts, /modules/videos, /deploy/environment