Rebasely Beta: data may be purged

examples/storefront Public

Load product images from the bucket's custom domain

Most of storefront-api's requests were image downloads that never change. storefront-images is now public on images.larkspur.shop, image_url points there, and GET /images/* is gone. Decision 0008 supersedes 0002.
c8458cc86afd8037e97ad916b4c92da482e2ec5f
Parent 39ab077 Browse filesHistory

Changed files

20 files changed112 additions78 deletionsagainst 39ab077

  1. Changed.rebasely/architecture/flows/image-upload.yaml+4−5
  2. Changed.rebasely/architecture/system.yaml+2−1
  3. Changed.rebasely/kb/concepts/carts.md+1−1
  4. Changed.rebasely/kb/concepts/catalog.md+1−1
  5. Changed.rebasely/kb/concepts/checkout.md+1−1
  6. Changed.rebasely/kb/concepts/product-images.md+5−3
  7. Changed.rebasely/kb/decisions/0002-serve-images-through-the-api.md+3−2
  8. Added.rebasely/kb/decisions/0008-images-from-the-bucket-domain.md+23−0
  9. Changed.rebasely/kb/guides/testing.md+2−2
  10. Changed.rebasely/kb/index.md+2−1
  11. ChangedREADME.md+1−2
  12. Changedsrc/app.ts+10−11
  13. Changedsrc/carts.ts+7−6
  14. Changedsrc/catalog.ts+9−8
  15. Changedsrc/checkout.ts+3−1
  16. Changedsrc/env.ts+1−0
  17. Changedsrc/images.ts+10−14
  18. Changedtest/helpers/app.ts+1−1
  19. Changedtest/images.test.ts+24−18
  20. Changedwrangler.jsonc+2−0
Changed.rebasely/architecture/flows/image-upload.yaml+4−5
@@ -1,14 +1,13 @@
title: Uploading a product image
description: >-
The merchant sends the image bytes to the API, which stores them in R2 under a hash of their
- content and points the product at the new key. Shoppers then load the image through the API.
+ content and points the product at the new key. Shoppers load the image from the bucket's custom
+ domain, so image requests never run the Worker.
actors: [merchant, shopper]
steps:
- { from: merchant, to: storefront-api, label: "PUT /admin/products/:id/image, admin token" }
- { from: storefront-api, to: db, label: check that the product exists }
- { from: storefront-api, to: images, label: "put products/<id>/<hash>.<ext>, cached a year" }
- { from: storefront-api, to: db, label: set the product's image_key }
- - { from: storefront-api, to: merchant, label: the product with its new image_url }
- - { from: shopper, to: storefront-api, label: GET /images/<key> }
- - { from: storefront-api, to: images, label: get the object }
- - { from: storefront-api, to: shopper, label: "the image, or 304 when the ETag matches" }
+ - { from: storefront-api, to: merchant, label: "the product, image_url on IMAGES_URL" }
+ - { from: shopper, to: images, label: "GET <IMAGES_URL>/<key>, from Cloudflare's cache" }
View file
Changed.rebasely/architecture/system.yaml+2−1
@@ -41,7 +41,8 @@
kind: r2-bucket
description: >-
Bucket storefront-images: product images under products/<product>/<hash>.<ext>. A key is
- never overwritten, so every image URL can be cached for a year.
+ never overwritten, so every image URL can be cached for a year. Public on its custom domain
+ (IMAGES_URL), where browsers load the images; storefront-api only writes to it.
binding: IMAGES
order-events:
kind: queue
View file
Changed.rebasely/kb/concepts/carts.md+1−1
@@ -7,7 +7,7 @@
- src/carts.ts
- src/money.ts
- migrations/0002_carts.sql
-reviewed: 2026-10-02
+reviewed: 2026-10-08
---
# Carts
View file
Changed.rebasely/kb/concepts/catalog.md+1−1
@@ -6,7 +6,7 @@
describes:
- src/catalog.ts
- migrations/0001_products.sql
-reviewed: 2026-10-01
+reviewed: 2026-10-08
---
# Catalog
View file
Changed.rebasely/kb/concepts/checkout.md+1−1
@@ -8,7 +8,7 @@
- src/orders.ts
- src/payments.ts
- migrations/0003_orders.sql
-reviewed: 2026-10-07
+reviewed: 2026-10-08
---
# Checkout
View file
Changed.rebasely/kb/concepts/product-images.md+5−3
@@ -5,11 +5,11 @@
component: storefront-api
describes:
- src/images.ts
-reviewed: 2026-10-01
+reviewed: 2026-10-08
---
# Product images
-Each product has at most one image. It lives in the `storefront-images` R2 bucket, and the product row keeps its key in `image_key`. The API turns the key into `image_url`.
+Each product has at most one image. It lives in the `storefront-images` R2 bucket, and the product row keeps its key in `image_key`. The API turns the key into `image_url` on the bucket's custom domain, `IMAGES_URL` in `wrangler.jsonc` (`imageUrls` in `src/images.ts`).
## Upload
@@ -23,4 +23,6 @@
## Serving
-`GET /images/<key>` reads the object from R2 and returns it with the stored headers and its ETag, or a 304 when `If-None-Match` matches. Only keys under `products/` are served. See [[0002-serve-images-through-the-api]] for why the Worker serves them.
+Browsers load images straight from the bucket's custom domain. R2 answers with the headers stored at upload, and Cloudflare's cache in front of the domain serves repeat requests, so image traffic does not run storefront-api at all ([[0008-images-from-the-bucket-domain]]). Until October 2026 the Worker served them at `GET /images/<key>` ([[0002-serve-images-through-the-api]]); that route is gone.
+
+The bucket is public on that domain only, so nothing but product images should be written to it.
View file
Changed.rebasely/kb/decisions/0002-serve-images-through-the-api.md+3−2
@@ -2,8 +2,9 @@
id: 0002-serve-images-through-the-api
type: decision
owner: justin
-status: accepted
-reviewed: 2026-10-01
+status: superseded
+superseded-by: 0008-images-from-the-bucket-domain
+reviewed: 2026-10-08
---
# 0002 Serve images through storefront-api
View file
Added.rebasely/kb/decisions/0008-images-from-the-bucket-domain.md+23−0
@@ -0,0 +1,23 @@
+---
+id: 0008-images-from-the-bucket-domain
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-08
+---
+# 0008 Serve images from the bucket's custom domain
+
+## Context
+
+[[0002-serve-images-through-the-api]] had storefront-api serve every image, and said to revisit it if image requests started to dominate. They have: a catalog page shows 24 products, so one page view is one API request and up to 24 image requests. Since the catalog went live, about 85% of the Worker's requests were `GET /images/*`, each one a Worker invocation returning bytes that never change. The shop also moved to its own domain, so a second hostname for images costs nothing to set up.
+
+## Decision
+
+Make `storefront-images` public on `images.larkspur.shop` (an R2 custom domain) and return image URLs on that domain, from `IMAGES_URL`. Remove `GET /images/*` from storefront-api. Uploads still go through the API with the admin token ([[product-images]]).
+
+## Consequences
+
+- Image requests are served by R2 and Cloudflare's cache and no longer count against the Worker.
+- Everything in the bucket is public. Only product images go in it; anything private needs another bucket.
+- The rule that only `products/` keys are reachable is gone with the route. It held only because the Worker enforced it.
+- Old `/images/...` URLs stop working. Nothing outside the shop's own pages used them, and those pages read `image_url` from the API.
View file
Changed.rebasely/kb/guides/testing.md+2−2
@@ -4,7 +4,7 @@
owner: justin
describes:
- test/helpers/**
-reviewed: 2026-10-06
+reviewed: 2026-10-08
---
# Running and writing tests
@@ -20,7 +20,7 @@
`test/helpers/app.ts` builds the real fetch handler from `createApp` with a fresh environment per test:
- `DB` is `TestD1` (`test/helpers/d1.ts`), a small D1 stand-in over `node:sqlite` with every file in `migrations/` applied in order and foreign keys on. It covers `prepare`, `bind`, `first`, `all`, `run` and `batch`, and a batch is a transaction, as in D1.
-- `IMAGES` is `TestR2` (`test/helpers/r2.ts`), an in-memory bucket.
+- `IMAGES` is `TestR2` (`test/helpers/r2.ts`), an in-memory bucket whose `objects` map tests can inspect, and `IMAGES_URL` is `https://images.shop.test`.
- `ORDER_EVENTS` is `TestQueue` (`test/helpers/queue.ts`), which keeps every message in `sent`.
- Payments are `FakePayments` (`test/helpers/payments.ts`), which records every charge and fails the next one when `failNext` is set. `test/stripe-payments.test.ts` checks the real Stripe adapter against a fake `fetch`, so no test calls Stripe.
- The clock is fixed and ids count up (`id-1`, `id-2`), so assertions can name them.
View file
Changed.rebasely/kb/index.md+2−1
@@ -15,12 +15,13 @@
## Decisions
- [[0001-d1-for-shop-data]]: one D1 database for everything the shop sells.
-- [[0002-serve-images-through-the-api]]: images are served by storefront-api.
+- [[0002-serve-images-through-the-api]]: images were served by storefront-api (superseded).
- [[0003-money-in-whole-cents]]: prices and totals are integers.
- [[0004-take-stock-at-checkout]]: carts check stock, checkout takes it.
- [[0005-stripe-payment-intents]]: payments are Stripe PaymentIntents.
- [[0006-one-writer-for-the-database]]: other Workers change orders through OrdersService.
- [[0007-at-least-once-with-idempotency-keys]]: repeats are expected and harmless.
+- [[0008-images-from-the-bucket-domain]]: browsers load images from R2's custom domain.
## Guides
View file
ChangedREADME.md+1−2
@@ -28,7 +28,7 @@
npm run dev
```
-Checkout needs Stripe: add `STRIPE_SECRET_KEY` to `.dev.vars` with a secret key from your Stripe account's test mode.
+Product images are served from the bucket's custom domain, `IMAGES_URL` in `wrangler.jsonc`. Checkout needs Stripe: add `STRIPE_SECRET_KEY` to `.dev.vars` with a secret key from your Stripe account's test mode.
Then add a product and look at the catalog:
@@ -47,7 +47,6 @@
| `GET /products/:id` | One active product |
| `PUT /admin/products/:id` | Create or replace a product (admin token) |
| `PUT /admin/products/:id/image` | Upload the product's image (admin token) |
-| `GET /images/<key>` | A product image |
| `POST /carts` | A new, empty cart |
| `GET /carts/:id` | A cart with its lines and totals |
| `PUT /carts/:id/items/:product` | Set a quantity, `{ "quantity": n }` |
View file
Changedsrc/app.ts+10−11
@@ -6,7 +6,7 @@
import { checkout } from "./checkout.ts";
import type { Env } from "./env.ts";
import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts";
-import { putProductImage, serveImage } from "./images.ts";
+import { imageUrls, putProductImage } from "./images.ts";
import { getOrder } from "./orders.ts";
import { stripePayments, type Payments } from "./payments.ts";
import { Router } from "./router.ts";
@@ -25,11 +25,12 @@
export function createApp(env: Env, deps: Deps = defaultDeps) {
const r = new Router();
+ const images = imageUrls(env.IMAGES_URL);
- r.on("GET", "/products", async () => json({ products: await listProducts(env.DB) }));
+ r.on("GET", "/products", async () => json({ products: await listProducts(env.DB, images) }));
r.on("GET", "/products/:id", async (_req, p) => {
- const product = await getProduct(env.DB, p.id!);
+ const product = await getProduct(env.DB, p.id!, images);
if (!product || !product.active) throw new HttpError(404, "not_found", "No such product.");
return json({ product });
});
@@ -37,31 +38,29 @@
r.on("PUT", "/admin/products/:id", async (req, p) => {
requireAdmin(req, env.ADMIN_TOKEN);
const input = parseProductInput(p.id!, await readJson(req));
- return json({ product: await upsertProduct(env.DB, input, deps.now()) });
+ return json({ product: await upsertProduct(env.DB, input, deps.now(), images) });
});
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!) });
+ return json({ product: await getProduct(env.DB, p.id!, images) });
});
- 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(), images) }, 201));
- r.on("POST", "/carts", async () => json({ cart: await createCart(env.DB, deps.newId(), deps.now()) }, 201));
-
r.on("GET", "/carts/:id", async (_req, p) => {
- const cart = await getCart(env.DB, p.id!);
+ const cart = await getCart(env.DB, p.id!, images);
if (!cart) throw new HttpError(404, "cart_not_found", "No such cart.");
return json({ cart });
});
r.on("PUT", "/carts/:id/items/:product", async (req, p) => {
const body = await readJson(req);
- return json({ cart: await setQuantity(env.DB, p.id!, p.product!, body.quantity, deps.now()) });
+ return json({ cart: await setQuantity(env.DB, p.id!, p.product!, body.quantity, deps.now(), images) });
});
- r.on("DELETE", "/carts/:id/items/:product", async (_req, p) => json({ cart: await setQuantity(env.DB, p.id!, p.product!, 0, deps.now()) }));
+ r.on("DELETE", "/carts/:id/items/:product", async (_req, p) => json({ cart: await setQuantity(env.DB, p.id!, p.product!, 0, deps.now(), images) }));
r.on("POST", "/carts/:id/checkout", async (req, p) => {
const result = await checkout(env.DB, deps.payments(env), p.id!, await readJson(req), deps);
View file
Changedsrc/carts.ts+7−6
@@ -1,6 +1,7 @@
// Carts: a shopper's items before checkout. A cart is open until it is checked out.
import { HttpError } from "./http.ts";
+import type { ImageUrls } from "./images.ts";
import { totals, type Totals } from "./money.ts";
export const MAX_QUANTITY = 10;
@@ -18,12 +19,12 @@
type ItemRow = { product_id: string; name: string; quantity: number; unit_price_cents: number; image_key: string | null };
-export async function createCart(db: D1Database, id: string, now: number): Promise<Cart> {
+export async function createCart(db: D1Database, id: string, now: number, images: ImageUrls): Promise<Cart> {
await db.prepare("INSERT INTO carts (id, created_at, updated_at) VALUES (?, ?, ?)").bind(id, now, now).run();
- return (await getCart(db, id))!;
+ return (await getCart(db, id, images))!;
}
-export async function getCart(db: D1Database, id: string): Promise<Cart | null> {
+export async function getCart(db: D1Database, id: string, images: ImageUrls): Promise<Cart | null> {
const cart = await db.prepare("SELECT id, status FROM carts WHERE id = ?").bind(id).first<{ id: string; status: Cart["status"] }>();
if (!cart) return null;
const { results } = await db
@@ -39,7 +40,7 @@
name: r.name,
quantity: r.quantity,
unit_price_cents: r.unit_price_cents,
- image_url: r.image_key ? `/images/${r.image_key}` : null,
+ image_url: r.image_key ? images(r.image_key) : null,
}));
return { id: cart.id, status: cart.status, items, ...totals(items) };
}
@@ -51,7 +52,7 @@
}
// Sets how many of a product the cart holds; 0 removes it. The price is the product's price now.
-export async function setQuantity(db: D1Database, cartId: string, productId: string, quantity: unknown, now: number): Promise<Cart> {
+export async function setQuantity(db: D1Database, cartId: string, productId: string, quantity: unknown, now: number, images: ImageUrls): Promise<Cart> {
if (!Number.isInteger(quantity) || (quantity as number) < 0 || (quantity as number) > MAX_QUANTITY) {
throw new HttpError(400, "bad_quantity", `quantity is a whole number from 0 to ${MAX_QUANTITY}.`);
}
@@ -81,5 +82,5 @@
.run();
}
await db.prepare("UPDATE carts SET updated_at = ? WHERE id = ?").bind(now, cartId).run();
- return (await getCart(db, cartId))!;
+ return (await getCart(db, cartId, images))!;
}
View file
Changedsrc/catalog.ts+9−8
@@ -1,6 +1,7 @@
// The product catalog: what the shop sells, at what price, and how many are left.
import { HttpError } from "./http.ts";
+import type { ImageUrls } from "./images.ts";
export type Product = {
id: string;
@@ -25,30 +26,30 @@
const COLUMNS = "id, name, description, price_cents, stock, image_key, active";
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
-export function toProduct(r: Row): Product {
+export function toProduct(r: Row, images: ImageUrls): Product {
return {
id: r.id,
name: r.name,
description: r.description,
price_cents: r.price_cents,
stock: r.stock,
- image_url: r.image_key ? `/images/${r.image_key}` : null,
+ image_url: r.image_key ? images(r.image_key) : null,
active: r.active === 1,
};
}
// Active products by name. The first 100 for now; a catalog bigger than that needs pages.
-export async function listProducts(db: D1Database): Promise<Product[]> {
+export async function listProducts(db: D1Database, images: ImageUrls): Promise<Product[]> {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 ORDER BY name, id LIMIT 100`)
.all<Row>();
- return results.map(toProduct);
+ return results.map((r) => toProduct(r, images));
}
// Any product, active or not. Shopper routes check `active` themselves.
-export async function getProduct(db: D1Database, id: string): Promise<Product | null> {
+export async function getProduct(db: D1Database, id: string, images: ImageUrls): Promise<Product | null> {
const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>();
- return row ? toProduct(row) : null;
+ return row ? toProduct(row, images) : null;
}
export type ProductInput = {
@@ -81,7 +82,7 @@
return { id, name: name.trim(), description, price_cents: price_cents as number, stock: stock as number, active };
}
-export async function upsertProduct(db: D1Database, p: ProductInput, now: number): Promise<Product> {
+export async function upsertProduct(db: D1Database, p: ProductInput, now: number, images: ImageUrls): Promise<Product> {
await db
.prepare(
`INSERT INTO products (id, name, description, price_cents, stock, active, created_at, updated_at)
@@ -92,5 +93,5 @@
)
.bind(p.id, p.name, p.description, p.price_cents, p.stock, p.active ? 1 : 0, now, now)
.run();
- return (await getProduct(db, p.id))!;
+ return (await getProduct(db, p.id, images))!;
}
View file
Changedsrc/checkout.ts+3−1
@@ -11,6 +11,8 @@
type Clock = { now: () => number; newId: () => string };
export async function checkout(db: D1Database, payments: Payments, cartId: string, body: Record<string, unknown>, deps: Clock): Promise<CheckoutResult> {
+ // Orders keep no image URLs, so the cart is read with bare keys.
+ const keysOnly = (key: string) => key;
const { email, ship_to } = parseCheckout(body);
// A retry after a timeout finds the order the first attempt created. Creating the intent is
@@ -23,7 +25,7 @@
return { order, client_secret: intent.client_secret, created: false };
}
- const cart = await getCart(db, cartId);
+ const cart = await getCart(db, cartId, keysOnly);
if (!cart) throw new HttpError(404, "cart_not_found", "No such cart.");
if (cart.status !== "open") throw new HttpError(409, "cart_closed", "This cart has been checked out.");
if (!cart.items.length) throw new HttpError(409, "cart_empty", "The cart is empty.");
View file
Changedsrc/env.ts+1−0
@@ -5,6 +5,7 @@
DB: D1Database;
IMAGES: R2Bucket;
ORDER_EVENTS: Queue<OrderEvent>;
+ IMAGES_URL: string; // the bucket's custom domain, where browsers load images
ADMIN_TOKEN: string;
STRIPE_SECRET_KEY: string;
}
View file
Changedsrc/images.ts+10−14
@@ -1,9 +1,18 @@
// 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.
+// for a year. Browsers load them from the bucket's custom domain (IMAGES_URL), not through this
+// Worker; see .rebasely/kb/decisions/0008-images-from-the-bucket-domain.md.
import { HttpError } from "./http.ts";
+// Turns an image key into the URL shoppers load it from.
+export type ImageUrls = (key: string) => string;
+
+export function imageUrls(base: string): ImageUrls {
+ const origin = base.replace(/\/+$/, "");
+ return (key) => `${origin}/${key}`;
+}
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
export const IMMUTABLE = "public, max-age=31536000, immutable";
@@ -32,19 +41,6 @@
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> {
View file
Changedtest/helpers/app.ts+1−1
@@ -13,7 +13,7 @@
export function setup() {
const events = testQueue<OrderEvent>();
- const env: Env = { DB: testDb(), IMAGES: testBucket(), ORDER_EVENTS: events, ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" };
+ const env: Env = { DB: testDb(), IMAGES: testBucket(), ORDER_EVENTS: events, IMAGES_URL: "https://images.shop.test", ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" };
const payments = new FakePayments();
let n = 0;
const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}`, payments: () => payments });
View file
Changedtest/images.test.ts+24−18
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setup } from "./helpers/app.ts";
+import type { TestR2 } from "./helpers/r2.ts";
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
@@ -8,34 +9,39 @@
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 bucket = (s: ReturnType<typeof setup>) => s.env.IMAGES as unknown as TestR2;
+const keyOf = (url: string) => new URL(url).pathname.slice(1);
+
+test("an uploaded image is stored under a content hash and linked on the bucket's domain", 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$/);
+ assert.match(res.body.product.image_url, /^https:\/\/images\.shop\.test\/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");
+ const stored = bucket(s).objects.get(keyOf(res.body.product.image_url))!;
+ assert.deepEqual([...stored.bytes], [...PNG]);
+ assert.equal(stored.httpMetadata.contentType, "image/png");
+ assert.equal(stored.httpMetadata.cacheControl, "public, max-age=31536000, immutable");
});
-test("a new image gets a new URL", async () => {
+test("the image URL shows up in the catalog and in carts", 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");
+ const url = (await upload(s, "wool-socks", PNG)).body.product.image_url;
+ assert.equal((await s.call("GET", "/products")).body.products[0].image_url, url);
+ const cartId = (await s.call("POST", "/carts")).body.cart.id;
+ const cart = (await s.call("PUT", `/carts/${cartId}/items/wool-socks`, { quantity: 1 })).body.cart;
+ assert.equal(cart.items[0].image_url, url);
});
-test("answers 304 when the browser already has the image", async () => {
+test("a new image gets a new key and the old object stays", 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);
+ 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.ok(bucket(s).objects.has(keyOf(first)), "carts and caches may still use the old URL");
});
test("refuses types that are not images and images over 5 MiB", async () => {
@@ -44,6 +50,7 @@
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);
+ assert.equal(bucket(s).objects.size, 0);
});
test("uploading needs the admin token and an existing product", async () => {
@@ -54,8 +61,7 @@
assert.equal(res.status, 401);
});
-test("only product images are served", async () => {
+test("the API no longer serves images itself", 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);
+ assert.equal((await s.call("GET", "/images/products/wool-socks/0011223344556677.png")).status, 404);
});
View file
Changedwrangler.jsonc+2−0
@@ -15,6 +15,8 @@
}
],
"r2_buckets": [{ "binding": "IMAGES", "bucket_name": "storefront-images" }],
+ // storefront-images is public on this custom domain (R2 > Settings > Custom Domains).
+ "vars": { "IMAGES_URL": "https://images.larkspur.shop" },
"queues": {
"producers": [{ "binding": "ORDER_EVENTS", "queue": "order-events" }]
}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.