Skip to content

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.

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 https schemes 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 (via platform/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 200 with a null body 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