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)
content.thumbnailMip.medium(or the size matching the current density) — our own CDN copy.content.thumbnailUrl— the originally-provided URL (from Open Graph, Twitter Card, etc.).- 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:
| Key | Purpose |
|---|---|
STORAGE_BUCKET | bucket name |
STORAGE_REGION | region |
STORAGE_ACCESS_KEY_ID | access key |
STORAGE_SECRET_ACCESS_KEY | secret |
STORAGE_PUBLIC_BASE_URL | CDN 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.approvedwith 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
| Env | Purpose |
|---|---|
STORAGE_BUCKET | Object storage bucket |
STORAGE_REGION | Region |
STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY | Credentials |
STORAGE_PUBLIC_BASE_URL | CDN URL prefix |
THUMBNAIL_FETCH_TIMEOUT_MS | Default 15000 |
THUMBNAIL_MAX_BYTES | Default 10485760 (10 MB) |
Services:
sharplibrary 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-imageContent-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.jpgreturns 404 for age-restricted or private videos.hqdefault.jpgoften works as a fallback. Ladder this insideresolveSource. - 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.thumbnailMiprows point to a dead origin. Either makethumbnailMipstore 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 withthumbnailUrl→ echoed back, nothing → null. - Unit:
extractYoutubeIdagainst 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 →thumbnailMippopulated. - Integration (failure): approve with an unreachable
thumbnailUrl;thumbnailMipstays unset; log line atwarn. - Integration (SSRF): submit with
http://169.254.169.254/→ resolver rejects; test the allow-list specifically.