Skip to main content

Thumbnails

Async mip generation pipeline triggered on content approval. Multi-source fallback ladder so a card always renders something.


Purpose​

A feed of 30 cards hot-linking full-size thumbnails from third-party platforms is slow and fragile. Generate small, medium, large pre-sized copies on our own CDN and serve those. Fall back gracefully when generation fails or the source is unavailable.

Contract reference: AgnosticUI.md §9.


Resolution ladder (render-time, in the client)​

  1. content.thumbnailMip.medium (or the size matching the current density) — our own CDN copy.
  2. content.thumbnailUrl — the originally-provided URL (from Open Graph, Twitter Card, etc.).
  3. Platform-colored placeholder with the title overlayed.

The client walks the ladder on each card. The server's job is to populate the first rung when it can.


Pipeline​

content.approved event
│
▼
ThumbnailsListener (async)
│
├── resolveSource(content) ──→ URL to fetch
│
├── fetch bytes (timeout 15s, max 10 MB)
│
├── generate mips { small: 320w, medium: 640w, large: 1280w } via sharp
│
├── upload to object storage (S3 / R2 / Spaces) at:
│ thumbs/<contentId>/small.webp
│ thumbs/<contentId>/medium.webp
│ thumbs/<contentId>/large.webp
│
└── update content.thumbnailMip with CDN URLs

Output format is WebP (smaller than JPEG at comparable quality). Fall back to JPEG for Accept negotiation if clients ever need it; today every target (Chrome extension, modern web) supports WebP.

resolveSource​

function resolveSource(c: Content): string | null {
if (c.thumbnailUrl) return c.thumbnailUrl;
if (c.platformSlug === 'youtube') {
const id = extractYoutubeId(c.url);
if (id) return `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`;
}
// More platform-specific fallbacks (twitter meta, bluesky embed...) can go here
return null;
}

If resolveSource returns null, skip silently — the client falls back to the placeholder.

Failure handling​

Any failure (fetch timeout, non-image response, sharp error, upload error) is caught in the listener. Log at warn level with contentId and the error. Do not retry automatically in v1 — moderator-visible "regenerate thumbnail" action covers the rare case of transient failures. Automated retry with backoff is v2.


Object storage​

v1 can use S3-compatible storage with the following shape:

KeyPurpose
STORAGE_BUCKETbucket name
STORAGE_REGIONregion
STORAGE_ACCESS_KEY_IDaccess key
STORAGE_SECRET_ACCESS_KEYsecret
STORAGE_PUBLIC_BASE_URLCDN URL prefix, e.g. https://cdn.example.com

Upload ACL: public-read. Objects are served directly by the CDN without the API proxying.

Before implementing, decide provider: Cloudflare R2 (no egress fees), AWS S3 (everywhere), DigitalOcean Spaces (lowest setup friction). R2 is the current recommendation for a low-cost MVP.


Regeneration​

POST /admin/content/<slug>/regenerate-thumbnail (admin-only, content.moderate):

  • Unset content.thumbnailMip.
  • Re-emit content.approved with a force flag OR call the generator directly.

Useful when:

  • Upstream thumbnail was replaced (e.g. YouTube video owner changed the video's frame).
  • Generation failed silently during approval.
  • Format upgrade (we switch from JPEG to WebP).

Required variables and services​

EnvPurpose
STORAGE_BUCKETObject storage bucket
STORAGE_REGIONRegion
STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEYCredentials
STORAGE_PUBLIC_BASE_URLCDN URL prefix
THUMBNAIL_FETCH_TIMEOUT_MSDefault 15000
THUMBNAIL_MAX_BYTESDefault 10485760 (10 MB)

Services:

  • sharp library for image resizing.
  • AWS SDK v3 (@aws-sdk/client-s3) — compatible with R2 and DO Spaces.

Gotchas​

  • Event ordering. Thumbnails listener must be registered; otherwise approval succeeds but thumbnails never generate. Smoke-test on deploy.
  • Fetch attacks. An attacker submits a URL pointing to a 10-GB file. THUMBNAIL_MAX_BYTES + timeout defends; also avoid following redirects beyond 3 hops; reject non-image Content-Type.
  • SSRF via resolveSource. A submitted URL could point to internal infrastructure. Never construct fetch URLs from raw submission data without an allow-list (hostnames must resolve to public IPs). Use a fetch library with egress allow-listing or do the check manually.
  • Private / age-gated content. YouTube's maxresdefault.jpg returns 404 for age-restricted or private videos. hqdefault.jpg often works as a fallback. Ladder this inside resolveSource.
  • Hotlink denial. Some platforms block hot-linked thumbnail requests from certain User-Agents. Set a polite UA ("${APP_NAME}/1.0 (+${APP_PUBLIC_URL})").
  • Our CDN URLs baked into documents. If the CDN domain changes, old content.thumbnailMip rows point to a dead origin. Either make thumbnailMip store a relative key + resolve at serialization time, or run a one-off script to rewrite.

Testing​

  • Unit: resolveSource — YouTube URL → i.ytimg.com/..., other URLs with thumbnailUrl → echoed back, nothing → null.
  • Unit: extractYoutubeId against a set of URL shapes (watch?v=, youtu.be/, embed/, with params).
  • Unit: mock fetch + sharp + upload; assert the listener updates content.thumbnailMip.
  • Integration: approve a content item with a known-good thumbnailUrl; wait for listener; GET the content → thumbnailMip populated.
  • Integration (failure): approve with an unreachable thumbnailUrl; thumbnailMip stays unset; log line at warn.
  • Integration (SSRF): submit with http://169.254.169.254/ → resolver rejects; test the allow-list specifically.