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.