examples/storefront Public
// Product images. The merchant uploads one image per product and it is stored in R2 under a key// that ends in a hash of its bytes, so a new image gets a new URL and every URL can be cached// for a year.import { HttpError } from "./http.ts";export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;export const IMMUTABLE = "public, max-age=31536000, immutable";const EXTENSIONS: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/avif": "avif",};// Stores the request body as the product's image and points the product at it. The previous// object stays in the bucket: carts and caches may still hold its URL.export async function putProductImage(db: D1Database, bucket: R2Bucket, productId: string, req: Request, now: number): Promise<string> { const exists = await db.prepare("SELECT 1 AS one FROM products WHERE id = ?").bind(productId).first<number>("one"); if (!exists) throw new HttpError(404, "product_not_found", "No such product."); const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); const ext = EXTENSIONS[type]; if (!ext) throw new HttpError(415, "unsupported_image", "Send a JPEG, PNG, WebP or AVIF image."); if (Number(req.headers.get("content-length") ?? 0) > MAX_IMAGE_BYTES) throw tooLarge(); const bytes = await req.arrayBuffer(); if (bytes.byteLength === 0) throw new HttpError(400, "empty_image", "The image is empty."); if (bytes.byteLength > MAX_IMAGE_BYTES) throw tooLarge(); const key = `products/${productId}/${await shortHash(bytes)}.${ext}`; await bucket.put(key, bytes, { httpMetadata: { contentType: type, cacheControl: IMMUTABLE } }); await db.prepare("UPDATE products SET image_key = ?, updated_at = ? WHERE id = ?").bind(key, now, productId).run(); return key;}// GET /images/<key>: the object from R2 with its stored headers. A matching If-None-Match gets// a 304 so browsers revalidate without downloading the image again.export async function serveImage(bucket: R2Bucket, key: string, req: Request): Promise<Response> { if (!key.startsWith("products/")) throw new HttpError(404, "not_found", "No such image."); const object = await bucket.get(key); if (!object) throw new HttpError(404, "not_found", "No such image."); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); if (req.headers.get("if-none-match") === object.httpEtag) return new Response(null, { status: 304, headers }); return new Response(object.body, { headers });}async function shortHash(bytes: ArrayBuffer): Promise<string> { const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); return [...digest.slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");}function tooLarge(): HttpError { return new HttpError(413, "image_too_large", "An image is at most 5 MiB.");}