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> showing cache-control: public, max-age=31536000, immutable → 09-media-headers.png
  • Cloudflare dashboard → Storage & Databases → R2 → lumina-media bucket 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 immutable caching 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.