Media¶
Purpose¶
The media domain (backend/internal/services/media/) is the client-facing edge for external media handling. It has three independent surfaces:
- Upload starters — issue short-lived presigned PUT URLs so the frontend uploads assets straight to S3, keeping backend egress out of the hot path.
- Klipy proxy — a server-side proxy for GIFs, stickers, clips, and memes that hides the upstream API key and protects its quota behind JWT.
- Link-preview proxy — an SSRF-guarded, Redis-cached fetcher for Open Graph metadata used to render link cards.
The heavy asset lifecycle (key generation, per-kind MIME gating, size caps, the sync/async delete split, and key↔URL translation) lives in the platform-tier asset service, backend/internal/platform/assets/. The upload starters are thin handlers that delegate to it; other domains finalise uploads by verifying the returned key before persisting it.
Surface area¶
Routes are registered by backend/internal/services/media/routes.go and mounted in internal/wiring/router.go. Gates are passed in as func(http.Handler) http.Handler middleware.
| Group | Registrar | Gate |
|---|---|---|
/api/v1/uploads/* |
RegisterUploadRoutes |
JWT + email-verified |
/api/v1/klipy/* |
RegisterKlipyRoutes |
JWT (applied to the group to protect the upstream quota) |
/api/v1/link-preview |
RegisterLinkPreviewRoute |
JWT + email-verified |
Upload starters¶
Every starter POSTs a { "content_type": "image/..." } body and returns { "key": "...", "upload_url": "https://..." }. The client PUTs the bytes to upload_url, then submits key back to the kind-specific finalise endpoint, which calls assets.AssertUploaded before mutating its model. upload_url is presigned with a short TTL (config.S3.SignedURLTTL, default 30 minutes) and binds both Content-Type and the per-kind size cap into the signature, so S3 rejects a mismatched MIME or an oversized body.
| Endpoint | Asset kind | Finalise on |
|---|---|---|
POST /uploads/avatar |
AssetAvatar |
PUT /users/me/avatar |
POST /uploads/group-avatar |
AssetGroupAvatar |
PUT /dm/rooms/{id}/avatar (needs room_id) |
POST /uploads/chat-image |
AssetChatImage |
chat WebSocket message image_keys (needs room_id) |
POST /uploads/chat-image/batch |
AssetChatImage × N |
chat WebSocket message (one presign per content_types[i], capped at chat.MaxChatImagesPerMessage) |
POST /uploads/moment |
AssetMoment |
POST /moments |
POST /uploads/item-image |
AssetItemImage |
image_keys on POST /items or POST /items/{id}/photos |
POST /uploads/event-cover |
AssetEventCover |
cover_image on POST / PATCH /events |
POST /uploads/plan-cover |
AssetPlanCover |
cover_image_key on PATCH /plans/{id} |
Check-ins carry no artifact, so they never presign anything; POST /checkins takes its fields directly.
Klipy proxy¶
KlipyHandler (klipy_handler.go) forwards to https://api.klipy.com/api/v1/{key}/{type}/{action}, injecting the server-held API key so it never reaches the client. Requests go through an httpx SafeClient (10s timeout).
| Method | Path | Upstream action |
|---|---|---|
| GET | /klipy/{type}/search |
search (forwards q, per_page, page, customer_id) |
| GET | /klipy/{type}/trending |
trending |
| POST | /klipy/{type}/share/{slug} |
share/{slug} (analytics ping) |
{type} is one of gifs, stickers, clips, static-memes. When no API key is configured the handler returns 503; an upstream failure returns 502.
Link-preview proxy¶
LinkPreviewHandler (link_preview_handler.go) fetches Open Graph metadata for a URL and returns LinkPreviewResult (title, description, image, url, domain). GET /link-preview?url=<https-url>.
- SSRF defense. Only
httpsschemes are accepted; the hostname is resolved and any private / loopback / link-local IP is rejected. The shared SafeClient resolves and re-rejects non-public IPs at dial time, defeating DNS rebinding. - Caching. Results are cached in Redis under
link-preview:<sha256(url)>for 1 hour (viaplatform/cache). - Body cap. The response body is read with a 64 KB limit; OG tags are matched against both attribute orderings, falling back to
<title>. - Graceful miss. On fetch error or when nothing useful is found, the handler returns
200with anullbody so the client simply skips the card.
Asset model (platform tier)¶
The upload starters produce keys owned by assets.Service in backend/internal/platform/assets/assets.go. AssetKind is the closed enum of writable asset types; each maps to exactly one storage.Prefix, owns a MIME allowlist, a size cap, and a key-scoping rule.
AssetKind |
value | storage.Prefix |
MIME set | Size cap | Key scoping |
|---|---|---|---|---|---|
AssetAvatar |
avatar |
avatars/ |
image | 5 MB | owner-prefixed |
AssetGroupAvatar |
group-avatar |
group-avatars/ |
image | 5 MB | flat |
AssetChatImage |
chat-image |
chat-images/ |
image + HEIC | 10 MB | nested (chat-images/<room_id>/…) |
AssetMoment |
moment |
moments/ |
image + HEIC | 15 MB | owner-prefixed |
AssetEventCover |
event-cover |
event-covers/ |
image | 15 MB | flat |
AssetPlanCover |
plan-cover |
plan-covers/ |
image | 15 MB | flat |
AssetItemImage |
item-image |
item-images/ |
image + HEIC | 15 MB | owner-prefixed |
AssetStamp |
stamp |
stamps/ |
image | 20 MB | flat (curated) |
AssetStampBespoke |
stamp-bespoke |
stamps/bespoke/ |
image | 20 MB | flat (curated) |
AssetCurio |
curio |
curios/ |
image | 20 MB | flat (curated) |
The two MIME sets are imageMIMEs (jpeg, png, gif, webp) and imageMIMEsWithHEIC (+ heic, heif). HEIC is reserved for capture-flow kinds (chat images, moments, item images) where iOS native uploads land; picker-UI kinds stay on the narrower set.
Key vs. URL¶
Models persist only the key — either in an AssetKey column or inside a JSONB envelope (chat-image keys live under chat_messages metadata). URLs compose on read via storage.PublicURL, which delegates to assets.Service.URLFromKey (wired at boot with storage.SetPublicURLFunc so models/ stays free of services imports). This decouples persisted data from CDN domain / bucket changes. URLFromKey passes any input containing :// through unchanged, so OAuth picture URLs (Google / Apple / LINE) can share the same column.
Write + verify¶
| Method | Role |
|---|---|
IssueUploadURL |
Returns (key, uploadURL); the standard client path. Signs Content-Type + size cap into the presigned PUT. |
AssertUploaded |
Called by each finalise endpoint before persisting: re-checks the key's prefix, owner-scoping, and HEADs the object to confirm it exists under the cap. Defends against a client submitting someone else's key. |
UploadRaw |
Server-internal upload from an io.Reader (sim seeds, generated avatars). |
Delete¶
| Entry | Semantics | When |
|---|---|---|
Delete |
Synchronous; blocks until S3 returns. | Replace flows (avatar rotation) where the old key must be gone before the new one is persisted. |
EnqueueDelete |
Pushes a cleanup task to the Asynq queue; returns once Redis acknowledges. Failed deletes retry, then surface via the admin DLQ inspector. |
Hard-delete cascades (user deletion, moment purge) where the DB transaction must not block on S3. |
Both accept a raw key or a stored URL and refuse keys whose prefix IsCurated() (anything under stamps/, stamps/bespoke/, curios/), so user-facing code can never wipe an admin-managed stamp or curio. The async delete handler re-checks the same guard on dequeue.
Where to look¶
backend/internal/services/media/upload_handler.go— presigned upload startersbackend/internal/services/media/klipy_handler.go— Klipy proxybackend/internal/services/media/link_preview_handler.go— OG metadata proxy + SSRF guardbackend/internal/services/media/routes.go— route registrationbackend/internal/platform/assets/assets.go—Service,AssetKind,kindSpecs, delete policybackend/internal/storage/prefixes.go—Prefixenum,IsCurated,PublicURLwiringbackend/internal/storage/s3_storage.go—PresignPut(binds Content-Type + size into the signature)backend/internal/async/handlers/cleanup.go— async-side curated-prefix re-checkfrontend/services/presignedUpload.ts,klipyService.ts,linkPreviewService.ts— the frontend clients