Product media on R2: validated uploads, immutable caching and AI-generated photos
Product photos sound boring until you list what can go wrong: someone uploads an SVG with a script in it, the cache serves last season’s art for a year, or replacing an image leaves a stale copy at the edge. The Lumina demo’s media pipeline handles all three with two conventions — validate by content, never trust the filename, and never reuse a URL.
The story
Images live in an R2 bucket (lumina-media) behind an admin-gated upload endpoint, and are
served back through the Worker at /media/* rather than from a public bucket. Serving
through the Worker keeps one access point, one place to stamp headers, and room for future
transforms (resizing, format conversion) without moving the data.
The unusual move: every upload — human or AI — mints a fresh key
(products/prd_merino_coat-1738017420000.jpg). Replacing an image is a new URL, so the old
object is deleted, caches never serve stale art, and long-lived cache headers are always safe.
How it works
Validate by content, not content-type. The multipart part’s content-type is client-controlled, so the format decision reads the file’s first bytes — and SVG is rejected outright because it can carry scripts:
export function detectImageKind(bytes: Uint8Array): 'jpeg' | 'png' | 'webp' | null {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png';
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[8] === 0x57) return 'webp';
return null;
}
Serve with an allowlist and immutable headers. The key pattern is strict — one
products/ segment, a safe charset, a known extension — so traversal-shaped URLs and odd
encodings 404 instead of leaking objects:
const MEDIA_KEY_PATTERN = /^products\/[A-Za-z0-9_-]+-[A-Za-z0-9]+\.(jpe?g|png|webp|svg)$/;
const object = await env.MEDIA.get(key);
if (!object) return jsonResponse({ error: 'Not found' }, 404);
headers.set('cache-control', 'public, max-age=31536000, immutable');
headers.set('x-content-type-options', 'nosniff');
headers.set('content-disposition', 'inline');
immutable for a year is correct because keys are unique per upload — the one convention
that makes the aggressive caching free of regret.
Generate the art with Workers AI. The admin panel can also generate a product image
in-Worker: an art-directed prompt per product (style preamble + per-product direction), run
through flux-1-schnell (base64, four steps) with SDXL raw-bytes as the fallback model —
and the output goes through the same magic-byte validation before it’s allowed to become a
product image:
const result = await env.AI.run('@cf/black-forest-labs/flux-1-schnell', { prompt, steps: 4 });
const bytes = base64ToBytes(result.image); // native Uint8Array.fromBase64 when available
const kind = detectImageKind(bytes.slice(0, 12));
if (!kind) return jsonResponse({ error: 'Model returned an unrecognized image format.' }, 502);
const key = `products/${productId}-ai-${Date.now()}.${ext}`;
await env.MEDIA.put(key, bytes, { httpMetadata: { contentType: mime } });
// update products.image_key, delete the previous object
All nine catalog images on the live storefront were generated this way — a style preamble (“premium e-commerce product photography, soft diffused studio lighting, no people, no text”) plus one art-direction line per product keeps the grid reading as one brand.
What the demo shows
Admin panel → Products tab: upload a photo (or click Generate), see the thumbnail update,
watch the storefront change. A curl -sI on any /media/* URL shows the immutable
cache-control header. And the R2 bucket view shows the flat, unique-keyed object list.
Evidence: what to capture
- Admin panel → Products tab with thumbnails + Upload/Generate controls →
09-admin-products.png - Generate an image live: before/after storefront card →
09-generate-before.png/09-generate-after.png -
curl -sI https://retail.mattwynne.solutions/media/products/<key>showingcache-control: public, max-age=31536000, immutable→09-media-headers.png - Cloudflare dashboard → Storage & Databases → R2 →
lumina-mediabucket object list →09-r2-bucket.png - (Optional) an upload rejection: a non-image file refused by magic-byte validation →
09-upload-reject.png
Key takeaways
- Trust file bytes, not filenames or client content-types — and refuse script-capable formats.
- Unique keys per upload make
immutablecaching safe forever; replacement is deletion + new URL. - Workers AI image models live one binding call away; validate their output with the same rigor as user uploads.