Rebasely Beta: data may be purged

examples/storefront Public

Store product images in R2 and serve them through the API

The merchant uploads an image with PUT /admin/products/:id/image. It is stored under a key that ends in a hash of its bytes, so a new image gets a new URL and every image URL can be cached for a year. GET /images/* streams the object back and answers If-None-Match with a 304.
49aabbc5032087a4a24ab84e89e49f880613cfaf
Parent f13d917 Browse filesHistory

Changed files

7 files changed188 additions4 deletionsagainst f13d917

  1. Changedsrc/app.ts+9−0
  2. Changedsrc/env.ts+1−0
  3. Addedsrc/images.ts+57−0
  4. Changedtest/helpers/app.ts+7−3
  5. Addedtest/helpers/r2.ts+51−0
  6. Addedtest/images.test.ts+61−0
  7. Changedwrangler.jsonc+2−1
Changedsrc/app.ts+9−0
@@ -5,6 +5,7 @@
import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts";
import type { Env } from "./env.ts";
import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts";
+import { putProductImage, serveImage } from "./images.ts";
import { Router } from "./router.ts";
export type Deps = {
@@ -30,6 +31,14 @@
const input = parseProductInput(p.id!, await readJson(req));
return json({ product: await upsertProduct(env.DB, input, deps.now()) });
});
+
+ r.on("PUT", "/admin/products/:id/image", async (req, p) => {
+ requireAdmin(req, env.ADMIN_TOKEN);
+ await putProductImage(env.DB, env.IMAGES, p.id!, req, deps.now());
+ return json({ product: await getProduct(env.DB, p.id!) });
+ });
+
+ r.on("GET", "/images/*", (req, p) => serveImage(env.IMAGES, p.rest!, req));
r.on("POST", "/carts", async () => json({ cart: await createCart(env.DB, deps.newId(), deps.now()) }, 201));
View file
Changedsrc/env.ts+1−0
@@ -1,5 +1,6 @@
// The bindings and secrets of the storefront-api Worker (wrangler.jsonc).
export interface Env {
DB: D1Database;
+ IMAGES: R2Bucket;
ADMIN_TOKEN: string;
}
View file
Addedsrc/images.ts+57−0
@@ -0,0 +1,57 @@
+// 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.");
+}
View file
Changedtest/helpers/app.ts+7−3
@@ -3,24 +3,28 @@
import { createApp } from "../../src/app.ts";
import type { Env } from "../../src/env.ts";
import { testDb } from "./d1.ts";
+import { testBucket } from "./r2.ts";
export const ADMIN_TOKEN = "test-admin-token";
export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0);
export function setup() {
- const env: Env = { DB: testDb(), ADMIN_TOKEN };
+ const env: Env = { DB: testDb(), IMAGES: testBucket(), ADMIN_TOKEN };
let n = 0;
const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}` });
async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const init: RequestInit = { method, headers: { ...headers } };
- if (body !== undefined) {
+ if (body instanceof Uint8Array) {
+ init.body = body;
+ } else if (body !== undefined) {
init.body = JSON.stringify(body);
(init.headers as Record<string, string>)["content-type"] = "application/json";
}
const res = await app.fetch(new Request(`https://shop.test${path}`, init));
const text = await res.text();
- return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : null };
+ const isJson = (res.headers.get("content-type") ?? "").startsWith("application/json");
+ return { status: res.status, headers: res.headers, body: isJson && text ? JSON.parse(text) : null, text };
}
const admin = { authorization: `Bearer ${ADMIN_TOKEN}` };
View file
Addedtest/helpers/r2.ts+51−0
@@ -0,0 +1,51 @@
+// An R2 stand-in kept in memory: put, get, head, delete, and the parts of R2Object the
+// storefront reads (body, httpEtag, httpMetadata, writeHttpMetadata).
+
+type Stored = { bytes: Uint8Array; httpMetadata: R2HTTPMetadata; etag: string };
+
+export class TestR2 {
+ readonly objects = new Map<string, Stored>();
+
+ async put(key: string, value: ArrayBuffer | ArrayBufferView | string, options: R2PutOptions = {}) {
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(ArrayBuffer.isView(value) ? value.buffer : value);
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
+ const etag = [...digest.slice(0, 16)].map((b) => b.toString(16).padStart(2, "0")).join("");
+ this.objects.set(key, { bytes, httpMetadata: (options.httpMetadata ?? {}) as R2HTTPMetadata, etag });
+ return this.view(key)!;
+ }
+
+ async head(key: string) {
+ return this.view(key);
+ }
+
+ async get(key: string) {
+ const v = this.view(key);
+ if (!v) return null;
+ const bytes = this.objects.get(key)!.bytes;
+ return { ...v, body: new Blob([bytes]).stream() };
+ }
+
+ async delete(keys: string | string[]) {
+ for (const k of Array.isArray(keys) ? keys : [keys]) this.objects.delete(k);
+ }
+
+ private view(key: string) {
+ const o = this.objects.get(key);
+ if (!o) return null;
+ return {
+ key,
+ size: o.bytes.byteLength,
+ etag: o.etag,
+ httpEtag: `"${o.etag}"`,
+ httpMetadata: o.httpMetadata,
+ writeHttpMetadata(headers: Headers) {
+ if (o.httpMetadata.contentType) headers.set("content-type", o.httpMetadata.contentType);
+ if (o.httpMetadata.cacheControl) headers.set("cache-control", o.httpMetadata.cacheControl);
+ },
+ };
+ }
+}
+
+export function testBucket(): R2Bucket {
+ return new TestR2() as unknown as R2Bucket;
+}
View file
Addedtest/images.test.ts+61−0
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { setup } from "./helpers/app.ts";
+
+const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
+
+async function upload(s: ReturnType<typeof setup>, id: string, bytes: Uint8Array, type = "image/png") {
+ return s.call("PUT", `/admin/products/${id}/image`, bytes, { ...s.admin, "content-type": type });
+}
+
+test("an uploaded image is stored under a content hash and linked from the product", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks");
+ const res = await upload(s, "wool-socks", PNG);
+ assert.equal(res.status, 200);
+ assert.match(res.body.product.image_url, /^\/images\/products\/wool-socks\/[0-9a-f]{16}\.png$/);
+
+ const img = await s.call("GET", res.body.product.image_url);
+ assert.equal(img.status, 200);
+ assert.equal(img.headers.get("content-type"), "image/png");
+ assert.equal(img.headers.get("cache-control"), "public, max-age=31536000, immutable");
+});
+
+test("a new image gets a new URL", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks");
+ const first = (await upload(s, "wool-socks", PNG)).body.product.image_url;
+ const second = (await upload(s, "wool-socks", new Uint8Array([...PNG, 9]))).body.product.image_url;
+ assert.notEqual(first, second);
+ assert.equal((await s.call("GET", first)).status, 200, "the old URL keeps working");
+});
+
+test("answers 304 when the browser already has the image", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks");
+ const url = (await upload(s, "wool-socks", PNG)).body.product.image_url;
+ const etag = (await s.call("GET", url)).headers.get("etag")!;
+ assert.equal((await s.call("GET", url, undefined, { "if-none-match": etag })).status, 304);
+});
+
+test("refuses types that are not images and images over 5 MiB", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks");
+ assert.equal((await upload(s, "wool-socks", PNG, "text/html")).status, 415);
+ assert.equal((await upload(s, "wool-socks", new Uint8Array(5 * 1024 * 1024 + 1))).status, 413);
+ assert.equal((await upload(s, "wool-socks", new Uint8Array(0))).status, 400);
+});
+
+test("uploading needs the admin token and an existing product", async () => {
+ const s = setup();
+ assert.equal((await upload(s, "nope", PNG)).body.error, "product_not_found");
+ await s.addProduct("wool-socks");
+ const res = await s.call("PUT", "/admin/products/wool-socks/image", PNG, { "content-type": "image/png" });
+ assert.equal(res.status, 401);
+});
+
+test("only product images are served", async () => {
+ const s = setup();
+ assert.equal((await s.call("GET", "/images/products/none.png")).status, 404);
+ assert.equal((await s.call("GET", "/images/private/report.pdf")).status, 404);
+});
View file
Changedwrangler.jsonc+2−1
@@ -13,5 +13,6 @@
"database_id": "",
"migrations_dir": "migrations"
}
- ]
+ ],
+ "r2_buckets": [{ "binding": "IMAGES", "bucket_name": "storefront-images" }]
}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.