Public: anyone can read this; the crew can change it.
Every refusal at the front door is a page of the shop, with a way back #88
Check · Review · Land · Live done — Landed and live in production.
mason/bazaar:mason/45-every-refusal-at-the-front-door-is-a-pag → main 1 commit fork
6 files
changed +381
−20
README.md +22
| @@ -452,6 +452,7 @@ saying so. | ||
| 452 | 452 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| 453 | 453 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 454 | 454 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 455 | | `src/pages/oops.ts` | The page a refusal wears: a status and a sentence in, a page of the shop with a way out of it — or the line a machine asked for | | |
| 455 | 456 | | `src/live.ts` `static/live.js` | The bus: a `live_event` row, and the stream that hands rows out across both processes; the one script, which refetches the `data-live` regions of the page it is on | |
| 456 | 457 | | `src/asset.ts` | A file under `static/`, addressed by its contents: the hash in the path has to be the hash of the bytes | |
| 457 | 458 | | `src/units.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | |
| @@ -475,6 +476,27 @@ is capped before it is verified and reads the result as `ctx.body`. A POST that | ||
| 475 | 476 | is not a form is `415`, one that is not same-origin or is missing the session's |
| 476 | 477 | CSRF token is `403`. |
| 477 | 478 | |
| 479 | ### What a shopper sees when one of those happens | |
| 480 | ||
| 481 | A refusal is a page of this shop, not a line of plain text on a white page: | |
| 482 | the header, the stylesheet, one sentence saying what happened, and a way out | |
| 483 | of it. `src/pages/oops.ts` renders all of them from a status and a sentence, | |
| 484 | and `src/server.ts` sends every "no" through it — the cross-site `403`, the | |
| 485 | stale-form `403`, `415`, `400`, `413`, `408` and `500` — as does the `429` a | |
| 486 | rate limit answers with. The shopper most likely to meet one is the one whose | |
| 487 | tab sat open until the session rolled over, mid-purchase: that page links back | |
| 488 | to `/cart` and to `/`, and it carries the new session cookie, so going back and | |
| 489 | sending the form again works rather than failing the same way twice. The `500` | |
| 490 | page says the shop failed and nothing else — no message, no path, no stack; | |
| 491 | `console.error` keeps the whole exception for the log. | |
| 492 | ||
| 493 | A machine is not dressed up. A caller whose `accept` header does not mention | |
| 494 | `text/html` gets the same line it always got (`forbidden`, `expected a form`, | |
| 495 | `that body was too large`), and `POST /webhooks/stripe` — the one route that | |
| 496 | reads its own body — answers plain text however it asks. Every one of these | |
| 497 | keeps its status, `cache-control: no-store`, and the security floor, because | |
| 498 | they all leave through the same `finish()` as every other response. | |
| 499 | ||
| 478 | 500 | The security floor on every response is in `src/server.ts`: a content security |
| 479 | 501 | policy with `script-src 'self'` — the one loosening this app has made, for the |
| 480 | 502 | one module under `static/` — and `form-action 'self'` (widened for one |
src/http.ts +9
| @@ -13,6 +13,15 @@ export const text = (body: string, status = 200): Response => | ||
| 13 | 13 | export const redirect = (location: string, status: 302 | 303 = 303): Response => |
| 14 | 14 | new Response(null, { status, headers: { location } }); |
| 15 | 15 | |
| 16 | /** | |
| 17 | * Whether the caller asked for a page. A browser navigating says so — every | |
| 18 | * navigation names text/html in `accept` — and a webhook, a script and the | |
| 19 | * wildcard curl sends do not. There is no guess here: the header either | |
| 20 | * mentions text/html or the caller gets a line. | |
| 21 | */ | |
| 22 | export const wantsHtml = (headers: Headers): boolean => | |
| 23 | (headers.get("accept") ?? "").toLowerCase().includes("text/html"); | |
| 24 | ||
| 16 | 25 | /** |
| 17 | 26 | * The header a handler uses to say "this response takes a form navigation to |
| 18 | 27 | * another origin". The security floor sets `form-action 'self'`, and Chromium |
src/pages/oops.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * The page a refusal wears. | |
| 3 | * | |
| 4 | * Every "no" the front door says is a page of this shop — the header, the | |
| 5 | * stylesheet, a sentence in a person's words and a way out of it — and not a | |
| 6 | * line of plain text on a white page. The shopper most likely to meet one is | |
| 7 | * the one whose tab sat open until the session rolled over, mid-purchase, with | |
| 8 | * a full cart, so the way out is a link back to the cart and to the shop. | |
| 9 | * | |
| 10 | * A machine is not dressed up: a caller whose `accept` does not mention | |
| 11 | * text/html gets the line it would have got before, and `respond` is where | |
| 12 | * that is decided. Nothing here is a guess about the caller — the header is | |
| 13 | * read for text/html and that is all. | |
| 14 | * | |
| 15 | * Nothing here reaches for a `Ctx`: the refusals that happen before one exists | |
| 16 | * render from what is known at the door and take the defaults for the rest. | |
| 17 | */ | |
| 18 | ||
| 19 | import { categories } from "../catalog.ts"; | |
| 20 | import { escape, layout } from "../html.ts"; | |
| 21 | import { html, text, wantsHtml } from "../http.ts"; | |
| 22 | import { ANON, type Who } from "../who.ts"; | |
| 23 | ||
| 24 | /** A way out of the page. */ | |
| 25 | export type Way = { href: string; label: string }; | |
| 26 | ||
| 27 | export type Oops = { | |
| 28 | status: number; | |
| 29 | /** The heading and the title: what happened, not a status code. */ | |
| 30 | title: string; | |
| 31 | /** One sentence saying what happened, as plain text; escaped here. */ | |
| 32 | said: string; | |
| 33 | /** Ways out, in order; the first is the button. Defaults to the shop. */ | |
| 34 | ways?: Way[]; | |
| 35 | /** The line a machine gets instead of the page. Defaults to `said`. */ | |
| 36 | line?: string; | |
| 37 | }; | |
| 38 | ||
| 39 | /** | |
| 40 | * What the shell needs and a refusal may not know: who the forge says is | |
| 41 | * asking, where signing in comes back to, how full the cart is. Anything left | |
| 42 | * out is a signed-out shopper with an empty cart looking at the shop's front. | |
| 43 | */ | |
| 44 | export type Shell = { who?: Who; path?: string; cartCount?: number }; | |
| 45 | ||
| 46 | const HOME: Way[] = [{ href: "/", label: "Back to the shop" }]; | |
| 47 | ||
| 48 | /** The cart first, because a refusal mid-purchase is the one that stings. */ | |
| 49 | export const BACK_TO_CART: Way[] = [ | |
| 50 | { href: "/cart", label: "Back to your cart" }, | |
| 51 | { href: "/", label: "The shop" }, | |
| 52 | ]; | |
| 53 | ||
| 54 | const button = (way: Way, first: boolean): string => | |
| 55 | `<a class="btn${first ? "" : " ghost"}" href="${escape(way.href)}">${escape(way.label)}</a>`; | |
| 56 | ||
| 57 | /** The refusal as a page of this shop. */ | |
| 58 | export function page(oops: Oops, shell: Shell = {}): string { | |
| 59 | const ways = oops.ways ?? HOME; | |
| 60 | return layout({ | |
| 61 | title: oops.title, | |
| 62 | who: shell.who ?? ANON, | |
| 63 | path: shell.path ?? "/", | |
| 64 | cartCount: shell.cartCount ?? 0, | |
| 65 | categories: categories(), | |
| 66 | body: `<h1>${escape(oops.title)}</h1> | |
| 67 | <p class="lede">${escape(oops.said)}</p> | |
| 68 | <p>${ways.map((way, index) => button(way, index === 0)).join(" ")}</p>`, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | /** The refusal as a response, for a caller that is known to be a browser. */ | |
| 73 | export const asPage = (oops: Oops, shell: Shell = {}): Response => html(page(oops, shell), oops.status); | |
| 74 | ||
| 75 | /** The page for a browser, the line for everything else; the status is kept. */ | |
| 76 | export const respond = (request: Request, oops: Oops, shell: Shell = {}): Response => | |
| 77 | wantsHtml(request.headers) ? asPage(oops, shell) : text(oops.line ?? oops.said, oops.status); |
src/server.test.ts +169
| @@ -14,6 +14,7 @@ const { stamp } = await import("./who.ts"); | ||
| 14 | 14 | const { server } = await import("./server.ts"); |
| 15 | 15 | const { db } = await import("./db.ts"); |
| 16 | 16 | const { isRefused, readBody, readForm, refusal } = await import("./http.ts"); |
| 17 | const { routes } = await import("./routes.ts"); | |
| 17 | 18 | const limit = await import("./ratelimit.ts"); |
| 18 | 19 | const live = await import("./live.ts"); |
| 19 | 20 | const giftcards = await import("./giftcards.ts"); |
| @@ -270,6 +271,174 @@ test("a post without the session's CSRF token is refused", async () => { | ||
| 270 | 271 | expect(await shopper.page("/cart")).toContain("Nothing in it yet"); |
| 271 | 272 | }); |
| 272 | 273 | |
| 274 | // --- a refusal is a page of the shop ---------------------------------------- | |
| 275 | ||
| 276 | /** What a browser navigating says it takes; a form POST carries it too. */ | |
| 277 | const BROWSER = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }; | |
| 278 | ||
| 279 | /** A page of this shop: its header, its stylesheet, and a way back out of it. */ | |
| 280 | function expectShopPage(response: Response, body: string): void { | |
| 281 | expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 282 | expect(response.headers.get("cache-control")).toBe("no-store"); | |
| 283 | // The floor is on a refusal like anything else. | |
| 284 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 285 | expect(body).toContain('<a class="brand" href="/">bazaar</a>'); | |
| 286 | expect(body).toContain('<link rel="stylesheet"'); | |
| 287 | expect(body).toContain('class="btn"'); | |
| 288 | expect(body).toContain("<h1>"); | |
| 289 | } | |
| 290 | ||
| 291 | /** A POST at the front door, said the way a client of that kind says it. */ | |
| 292 | const knock = (path: string, init: RequestInit & { headers: Record<string, string> }): Promise<Response> => | |
| 293 | fetch(base + path, { method: "POST", redirect: "manual", ...init }); | |
| 294 | ||
| 295 | test("a cross-site post is a page of the shop for a browser and a line for anything else", async () => { | |
| 296 | const cross = { origin: "https://evil.example", "sec-fetch-site": "cross-site" }; | |
| 297 | const page = await knock("/cart/add", { | |
| 298 | body: new URLSearchParams({ variant: "1" }), | |
| 299 | headers: { ...cross, ...BROWSER, "content-type": "application/x-www-form-urlencoded" }, | |
| 300 | }); | |
| 301 | expect(page.status).toBe(403); | |
| 302 | const body = await page.text(); | |
| 303 | expectShopPage(page, body); | |
| 304 | expect(body).toContain("sent from somewhere else"); | |
| 305 | expect(body).toContain("Back to the shop"); | |
| 306 | ||
| 307 | const machine = await knock("/cart/add", { | |
| 308 | body: new URLSearchParams({ variant: "1" }), | |
| 309 | headers: { ...cross, "content-type": "application/x-www-form-urlencoded" }, | |
| 310 | }); | |
| 311 | expect(machine.status).toBe(403); | |
| 312 | expect(machine.headers.get("content-type")).toContain("text/plain"); | |
| 313 | expect(await machine.text()).toBe("forbidden"); | |
| 314 | }); | |
| 315 | ||
| 316 | test("a form that went stale is a page with the way back on it, and the session comes with it", async () => { | |
| 317 | // A tab that sat open until its session rolled over: no cookie at all, so | |
| 318 | // the refusal has to hand out the session the next attempt will sign with. | |
| 319 | const stale = await knock("/cart/add", { | |
| 320 | body: new URLSearchParams({ variant: String(variantId("COF-GUJI-250")), quantity: "1", csrf: "not-the-token" }), | |
| 321 | headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/x-www-form-urlencoded" }, | |
| 322 | }); | |
| 323 | expect(stale.status).toBe(403); | |
| 324 | const body = await stale.text(); | |
| 325 | expectShopPage(stale, body); | |
| 326 | expect(body).toContain("had gone stale"); | |
| 327 | expect(body).toContain('href="/cart"'); | |
| 328 | expect(body).toContain('href="/"'); | |
| 329 | ||
| 330 | const issued = stale.headers.getSetCookie().find((line) => line.startsWith("sid=")); | |
| 331 | expect(issued).toBeDefined(); | |
| 332 | expect(issued).toContain("HttpOnly"); | |
| 333 | ||
| 334 | // And going back and sending the form again works, rather than failing the | |
| 335 | // same way twice: the cookie this refusal set is the one the page's token is | |
| 336 | // from. | |
| 337 | const shopper = new Shopper(); | |
| 338 | shopper.jar.set("sid", issued!.split(";")[0]!.split("=")[1]!); | |
| 339 | const product = await shopper.page(`/p/${slugOf("COF-GUJI-250")}`); | |
| 340 | const again = await shopper.post( | |
| 341 | "/cart/add", | |
| 342 | { variant: String(variantId("COF-GUJI-250")), quantity: "1" }, | |
| 343 | Shopper.tokenIn(product), | |
| 344 | ); | |
| 345 | expect(again.status).toBe(303); | |
| 346 | }); | |
| 347 | ||
| 348 | test("a body that is not a form, and a form that did not arrive whole, are pages too", async () => { | |
| 349 | const notAForm = await knock("/cart/add", { | |
| 350 | body: JSON.stringify({ variant: 1 }), | |
| 351 | headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/json" }, | |
| 352 | }); | |
| 353 | expect(notAForm.status).toBe(415); | |
| 354 | expectShopPage(notAForm, await notAForm.text()); | |
| 355 | ||
| 356 | const halfAForm = await knock("/cart/add", { | |
| 357 | body: "this was never a multipart body", | |
| 358 | headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "multipart/form-data; boundary=nope" }, | |
| 359 | }); | |
| 360 | expect(halfAForm.status).toBe(400); | |
| 361 | expect(await halfAForm.text()).toContain("did not arrive whole"); | |
| 362 | ||
| 363 | // The lines a machine gets are the ones this app has always sent. | |
| 364 | const line = await knock("/cart/add", { | |
| 365 | body: JSON.stringify({ variant: 1 }), | |
| 366 | headers: { "sec-fetch-site": "same-origin", "content-type": "application/json" }, | |
| 367 | }); | |
| 368 | expect(line.status).toBe(415); | |
| 369 | expect(await line.text()).toBe("expected a form"); | |
| 370 | }); | |
| 371 | ||
| 372 | test("a body past the cap is a page for a browser, and stays a line for the webhook", async () => { | |
| 373 | const big = "a".repeat(200 * 1024); | |
| 374 | const page = await knock("/cart/add", { | |
| 375 | body: new URLSearchParams({ variant: "1", filler: big }), | |
| 376 | headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/x-www-form-urlencoded" }, | |
| 377 | }); | |
| 378 | expect(page.status).toBe(413); | |
| 379 | expectShopPage(page, await page.text()); | |
| 380 | expect(page.headers.get("connection")).toBe("close"); | |
| 381 | ||
| 382 | // Stripe's webhook is a machine's door however it asks: a raw route is never | |
| 383 | // dressed up, even when the caller claims to take HTML. | |
| 384 | const webhook = await knock("/webhooks/stripe", { | |
| 385 | body: JSON.stringify({ type: "checkout.session.completed", padding: big }), | |
| 386 | headers: { ...BROWSER, "content-type": "application/json", "stripe-signature": "t=1,v1=nope" }, | |
| 387 | }); | |
| 388 | expect(webhook.status).toBe(413); | |
| 389 | expect(webhook.headers.get("content-type")).toContain("text/plain"); | |
| 390 | expect(await webhook.text()).toBe("that body was too large"); | |
| 391 | }); | |
| 392 | ||
| 393 | test("the 500 page says what happened to the log and nothing at all to the shopper", async () => { | |
| 394 | // The only way to a 500 is a handler that throws, so one is lent one for the | |
| 395 | // length of this test and given back in the finally below. | |
| 396 | const route = routes.find((entry) => entry.method === "GET" && entry.pattern === "/search")!; | |
| 397 | const real = route.handler; | |
| 398 | const secret = "/srv/bazaar/src/shop.ts:412 — SQLITE_ERROR near \"SELECT\""; | |
| 399 | const said: unknown[][] = []; | |
| 400 | const spoke = console.error; | |
| 401 | console.error = (...args: unknown[]) => void said.push(args); | |
| 402 | route.handler = () => { | |
| 403 | throw new Error(secret); | |
| 404 | }; | |
| 405 | try { | |
| 406 | const response = await fetch(base + "/search?q=espresso", { headers: BROWSER }); | |
| 407 | expect(response.status).toBe(500); | |
| 408 | const body = await response.text(); | |
| 409 | expectShopPage(response, body); | |
| 410 | expect(body).toContain("the shop failed on its own"); | |
| 411 | // Not the message, not the file, not the stack, not a word of any of it. | |
| 412 | expect(body).not.toContain(secret); | |
| 413 | expect(body).not.toContain("SQLITE"); | |
| 414 | expect(body).not.toContain("shop.ts"); | |
| 415 | expect(body).not.toContain("Error"); | |
| 416 | // The log keeps all of it. | |
| 417 | expect(said.flat().some((part) => part instanceof Error && part.message === secret)).toBe(true); | |
| 418 | ||
| 419 | const line = await fetch(base + "/search?q=espresso"); | |
| 420 | expect(line.status).toBe(500); | |
| 421 | expect(await line.text()).toBe("something went wrong"); | |
| 422 | } finally { | |
| 423 | route.handler = real; | |
| 424 | console.error = spoke; | |
| 425 | } | |
| 426 | }); | |
| 427 | ||
| 428 | test("a limit a shopper meets is the same page, with its retry-after", async () => { | |
| 429 | const shopper = new Shopper(); | |
| 430 | const form = Shopper.tokenIn(await shopper.page("/gift-cards")); | |
| 431 | let last = new Response(); | |
| 432 | for (let attempt = 0; attempt < limit.BALANCE.capacity + 2; attempt++) | |
| 433 | last = await shopper.post("/gift-cards", { code: "BZ-NOPE" }, form); | |
| 434 | expect(last.status).toBe(429); | |
| 435 | expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 436 | const body = await last.text(); | |
| 437 | expectShopPage(last, body); | |
| 438 | expect(body).toContain("Slow down"); | |
| 439 | limit.forget(); | |
| 440 | }); | |
| 441 | ||
| 273 | 442 | // --- the cap on a body ------------------------------------------------------- |
| 274 | 443 | |
| 275 | 444 | /** A body that arrives in chunks and so carries no Content-Length at all. */ |
src/server.ts +90 −9
| @@ -8,6 +8,10 @@ | ||
| 8 | 8 | * every response, refuses a POST that is not same-origin and does not carry |
| 9 | 9 | * the session's CSRF token, works out who the forge says is asking, builds |
| 10 | 10 | * the request's context, and hands over to the table in src/routes.ts. |
| 11 | * | |
| 12 | * Every "no" it says is a page of the shop, rendered by src/pages/oops.ts — | |
| 13 | * with the shop's header on it and a way back out — for a caller that asked | |
| 14 | * for one, and the same line of plain text as ever for a caller that did not. | |
| 11 | 15 | */ |
| 12 | 16 | |
| 13 | 17 | import { dataDir, db, forgetPasswordSettings } from "./db.ts"; |
| @@ -32,6 +36,8 @@ import { seed as seedDiscounts } from "./discounts.ts"; | ||
| 32 | 36 | import { seed as seedTax } from "./tax.ts"; |
| 33 | 37 | import { HAND_OFF, isRefused, readForm, readText, refusal, text, type Refused } from "./http.ts"; |
| 34 | 38 | import { returnTo, whoIs } from "./who.ts"; |
| 39 | import { BACK_TO_CART, respond as oops, type Oops, type Shell } from "./pages/oops.ts"; | |
| 40 | import { cartCount } from "./context.ts"; | |
| 35 | 41 | import type { Who } from "./who.ts"; |
| 36 | 42 | import type { Ctx } from "./context.ts"; |
| 37 | 43 | |
| @@ -89,13 +95,77 @@ const sameOrigin = (request: Request): boolean => { | ||
| 89 | 95 | return origin === null || origin === publicOrigin; |
| 90 | 96 | }; |
| 91 | 97 | |
| 98 | // Every refusal this door makes, as the sentence a shopper reads and the line | |
| 99 | // a machine gets. The lines are the ones this app has always sent, because a | |
| 100 | // caller that is not a browser has no use for manners; the pages are new. The | |
| 101 | // 500's sentence is the whole of what a shopper is told: what went wrong goes | |
| 102 | // to console.error and nowhere near a response. | |
| 103 | const CROSS_SITE: Oops = { | |
| 104 | status: 403, | |
| 105 | title: "That came from another site", | |
| 106 | said: "This shop only acts on its own forms, and that request was sent from somewhere else. Nothing was changed.", | |
| 107 | line: "forbidden", | |
| 108 | }; | |
| 109 | ||
| 110 | const STALE_FORM: Oops = { | |
| 111 | status: 403, | |
| 112 | title: "That form was not from here", | |
| 113 | said: | |
| 114 | "Forms here carry a token that says they came from this shop, and this form's token had gone stale — usually " + | |
| 115 | "because the tab sat open a while. Your cart is untouched: open it again and send the form from there.", | |
| 116 | ways: BACK_TO_CART, | |
| 117 | line: "that form was not from here — go back and try again", | |
| 118 | }; | |
| 119 | ||
| 120 | const NOT_A_FORM: Oops = { | |
| 121 | status: 415, | |
| 122 | title: "That was not a form", | |
| 123 | said: "This address takes a form the shop served, and that request was something else.", | |
| 124 | line: "expected a form", | |
| 125 | }; | |
| 126 | ||
| 127 | const BAD_FORM: Oops = { | |
| 128 | status: 400, | |
| 129 | title: "That form did not arrive whole", | |
| 130 | said: "The form came through in a shape the shop could not read. Go back and send it again.", | |
| 131 | ways: BACK_TO_CART, | |
| 132 | line: "bad form", | |
| 133 | }; | |
| 134 | ||
| 135 | const BROKEN: Oops = { | |
| 136 | status: 500, | |
| 137 | title: "Something went wrong", | |
| 138 | said: "That is ours, not yours: the shop failed on its own. Nothing was charged. Try it again in a moment.", | |
| 139 | line: "something went wrong", | |
| 140 | }; | |
| 141 | ||
| 142 | const TOO_MUCH: Oops = { | |
| 143 | status: 413, | |
| 144 | title: "That was more than the shop takes", | |
| 145 | said: "The shop stopped reading that request: it was larger than anything a form here needs to be.", | |
| 146 | line: refusal(413), | |
| 147 | }; | |
| 148 | ||
| 149 | const TOO_SLOW: Oops = { | |
| 150 | status: 408, | |
| 151 | title: "That took too long to arrive", | |
| 152 | said: "The shop waited for the rest of that request and stopped. Go back and send it again.", | |
| 153 | ways: BACK_TO_CART, | |
| 154 | line: refusal(408), | |
| 155 | }; | |
| 156 | ||
| 157 | const refused = (status: Refused): Oops => (status === 413 ? TOO_MUCH : TOO_SLOW); | |
| 158 | ||
| 92 | 159 | /** |
| 93 | 160 | * The answer to a body we stopped reading. It says close, and means it: bytes |
| 94 | 161 | * of that body are still on the wire, and the next request on this connection |
| 95 | 162 | * would read them as its own. A refusal ends the connection with the request. |
| 163 | * | |
| 164 | * `dressed` is false for the routes a machine calls — Stripe's webhook reads | |
| 165 | * its own body — where a page would be noise in a log. | |
| 96 | 166 | */ |
| 97 | function refuse(status: Refused): Response { | |
| 98 | const response = text(refusal(status), status); | |
| 167 | function refuse(request: Request, status: Refused, shell: Shell, dressed = true): Response { | |
| 168 | const response = dressed ? oops(request, refused(status), shell) : text(refusal(status), status); | |
| 99 | 169 | response.headers.set("connection", "close"); |
| 100 | 170 | return response; |
| 101 | 171 | } |
| @@ -146,7 +216,9 @@ export const server = Bun.serve({ | ||
| 146 | 216 | [], |
| 147 | 217 | ); |
| 148 | 218 | |
| 149 | if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []); | |
| 219 | // Nothing of this request is decoded to answer it — no session issued, no | |
| 220 | // cart opened — so the page it gets is the shop's front and a way into it. | |
| 221 | if (method === "POST" && !sameOrigin(request)) return finish(oops(request, CROSS_SITE), []); | |
| 150 | 222 | |
| 151 | 223 | const found = match(method, url.pathname); |
| 152 | 224 | // Where a sign-in on this page comes back to, decided once and for every |
| @@ -172,27 +244,35 @@ export const server = Bun.serve({ | ||
| 172 | 244 | // wall-clock budget so a trickling client cannot keep a handler open — |
| 173 | 245 | // including the one route that reads its own body, Stripe's webhook, which |
| 174 | 246 | // gets it as ctx.body. A refusal comes back as the status to send. |
| 247 | // | |
| 248 | // A refusal from here on is a page of the shop: the session about to be | |
| 249 | // set, who the forge says is asking and the cart in the cookie are all | |
| 250 | // known, and `outgoing` carries the session cookie with it — so a shopper | |
| 251 | // who goes back and sends the form again is not refused the same way twice. | |
| 252 | // Worked out only when one of them happens; the request that is answered | |
| 253 | // decodes its cart once, in its Ctx. | |
| 254 | const shell = (): Shell => ({ who, path: backTo, cartCount: cartCount(decodeCart(cookies[CART_COOKIE], secret)) }); | |
| 175 | 255 | let form: FormData | null = null; |
| 176 | 256 | let body: string | null = null; |
| 177 | 257 | if (method === "POST" && found.route.raw) { |
| 178 | 258 | const read = await readText(request); |
| 179 | if (isRefused(read)) return finish(refuse(read), outgoing); | |
| 259 | if (isRefused(read)) return finish(refuse(request, read, shell(), false), outgoing); | |
| 180 | 260 | body = read; |
| 181 | 261 | } |
| 182 | 262 | if (method === "POST" && !found.route.raw) { |
| 183 | 263 | const type = request.headers.get("content-type") ?? ""; |
| 184 | 264 | if (!type.startsWith("application/x-www-form-urlencoded") && !type.startsWith("multipart/form-data")) |
| 185 | return finish(text("expected a form", 415), outgoing); | |
| 265 | return finish(oops(request, NOT_A_FORM, shell()), outgoing); | |
| 186 | 266 | let read: FormData | Refused; |
| 187 | 267 | try { |
| 188 | 268 | read = await readForm(request); |
| 189 | 269 | } catch { |
| 190 | return finish(text("bad form", 400), outgoing); | |
| 270 | return finish(oops(request, BAD_FORM, shell()), outgoing); | |
| 191 | 271 | } |
| 192 | if (isRefused(read)) return finish(refuse(read), outgoing); | |
| 272 | if (isRefused(read)) return finish(refuse(request, read, shell()), outgoing); | |
| 193 | 273 | form = read; |
| 194 | 274 | if (found.route.csrf !== false && !checkCsrf(session, form.get("csrf"))) |
| 195 | return finish(text("that form was not from here — go back and try again", 403), outgoing); | |
| 275 | return finish(oops(request, STALE_FORM, shell()), outgoing); | |
| 196 | 276 | } |
| 197 | 277 | |
| 198 | 278 | const ctx = context(request, url, found.params, session, who, backTo, form, body, cookies, outgoing, self); |
| @@ -200,8 +280,9 @@ export const server = Bun.serve({ | ||
| 200 | 280 | const response = await found.route.handler(ctx); |
| 201 | 281 | return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing); |
| 202 | 282 | } catch (error) { |
| 283 | // The log keeps the exception whole; the shopper is told a sentence. | |
| 203 | 284 | console.error(`${method} ${url.pathname}:`, error); |
| 204 | return finish(text("something went wrong", 500), outgoing); | |
| 285 | return finish(oops(request, BROKEN, { who, path: backTo, cartCount: cartCount(ctx.cart) }), outgoing); | |
| 205 | 286 | } |
| 206 | 287 | }, |
| 207 | 288 | }); |
src/shop.ts +14 −11
| @@ -19,6 +19,7 @@ import * as limit from "./ratelimit.ts"; | ||
| 19 | 19 | import * as mine from "./mine.ts"; |
| 20 | 20 | import * as orders from "./orders.ts"; |
| 21 | 21 | import * as giftCardPages from "./pages/giftcard.ts"; |
| 22 | import * as oops from "./pages/oops.ts"; | |
| 22 | 23 | import * as pages from "./pages/checkout.ts"; |
| 23 | 24 | import * as storefront from "./pages/storefront.ts"; |
| 24 | 25 | import * as tax from "./tax.ts"; |
| @@ -35,14 +36,13 @@ import { | ||
| 35 | 36 | } from "./catalog.ts"; |
| 36 | 37 | import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts"; |
| 37 | 38 | import { page as ordersPageView } from "./pages/orders.ts"; |
| 38 | import { render, type Ctx } from "./context.ts"; | |
| 39 | import { cartCount, render, type Ctx } from "./context.ts"; | |
| 39 | 40 | import { page as cartPage } from "./pages/cart.ts"; |
| 40 | 41 | import { chosen } from "./gateway.ts"; |
| 41 | 42 | import { equals, hmac, sign, unsign } from "./sign.ts"; |
| 42 | 43 | import { forOrder } from "./mail.ts"; |
| 43 | 44 | import { linkTo, opensByAccount } from "./orderlink.ts"; |
| 44 | 45 | import { dueCents, parseQuantity } from "./money.ts"; |
| 45 | import { escape } from "./html.ts"; | |
| 46 | 46 | import type { Order } from "./db.ts"; |
| 47 | 47 | |
| 48 | 48 | const publicOrigin = (): string => new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; |
| @@ -593,14 +593,17 @@ export function stripeWebhook(ctx: Ctx): Response { | ||
| 593 | 593 | // --- limits ----------------------------------------------------------------- |
| 594 | 594 | |
| 595 | 595 | export function tooMany(ctx: Ctx, seconds: number, what: string): Response { |
| 596 | const body = render( | |
| 597 | ctx, | |
| 598 | { title: "Slow down", status: 429 }, | |
| 599 | `<h1>Slow down</h1><p class="lede">Too many ${escape(what)} attempts from here. Try again in ${seconds} second${ | |
| 600 | seconds === 1 ? "" : "s" | |
| 601 | }.</p>`, | |
| 596 | // The same page as every other refusal, from src/pages/oops.ts: a limit a | |
| 597 | // shopper meets is no plainer an answer than a form that went stale. It is | |
| 598 | // always the page — whoever trips a limit here got here from a page. | |
| 599 | const response = oops.asPage( | |
| 600 | { | |
| 601 | status: 429, | |
| 602 | title: "Slow down", | |
| 603 | said: `Too many ${what} attempts from here. Try again in ${seconds} second${seconds === 1 ? "" : "s"}.`, | |
| 604 | }, | |
| 605 | { who: ctx.who, path: ctx.backTo, cartCount: cartCount(ctx.cart) }, | |
| 602 | 606 | ); |
| 603 | const headers = new Headers(body.headers); | |
| 604 | headers.set("retry-after", String(Math.max(1, seconds))); | |
| 605 | return new Response(body.body, { status: 429, headers }); | |
| 607 | response.headers.set("retry-after", String(Math.max(1, seconds))); | |
| 608 | return response; | |
| 606 | 609 | } |