Public: anyone can read this; the crew can change it.
Build bazaar #5
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/1-build-bazaar → main 8 commits fork 1 thread · 1 unresolved
18 files
changed +660
−69
README.md +58 −8
| @@ -5,8 +5,10 @@ variants and stock, a cart in a signed cookie, discount codes and gift cards, | ||
| 5 | 5 | tax by where the parcel goes, checkout with an address, a payment step, |
| 6 | 6 | receipts, and an `/admin` for products, inventory, codes, cards, tax and |
| 7 | 7 | orders with refunds. Every price is an integer number of cents, every form |
| 8 | carries a CSRF token, and checkout, payment and typing a code are rate | |
| 9 | limited. | |
| 8 | carries a CSRF token, and checkout, payment, typing a code and finding an | |
| 9 | order are rate limited. A category or a search longer than 24 things is | |
| 10 | paged, so a listing is never a page without an end and never hides what is | |
| 11 | past the end of it. | |
| 10 | 12 | |
| 11 | 13 | This is an app: the forge runs it. Production follows the default branch; |
| 12 | 14 | every open pull gets a preview at its own address with its own copy of the |
| @@ -35,7 +37,8 @@ library are all of it. There is no client-side JavaScript. | ||
| 35 | 37 | Decline buttons, that moves no money. |
| 36 | 38 | - **Never real**: email. There is no mail server. A receipt is composed and |
| 37 | 39 | stored, shown as an email-style page at `/orders/<number>/receipt` and |
| 38 | listed in the admin outbox at `/admin/mail`. | |
| 40 | listed in the admin outbox at `/admin/mail`. Because no receipt is ever | |
| 41 | delivered, `/orders` is how a shopper gets back to one — see below. | |
| 39 | 42 | - No goods ship. It is a demonstration store, and every page says so. |
| 40 | 43 | |
| 41 | 44 | ## Codes, cards and tax |
| @@ -75,6 +78,51 @@ the goods, plus gift cards being bought — and only then is a card's balance | ||
| 75 | 78 | taken off to leave what the gateway is asked for. `bun test` reconciles a |
| 76 | 79 | cart with all three against the order it becomes. |
| 77 | 80 | |
| 81 | ## Finding an order again | |
| 82 | ||
| 83 | An order is private, so it opens with its own access token and nothing else — | |
| 84 | which leaves the shopper who closed the tab with no way back. `/orders` is | |
| 85 | that way back, and it has two halves: | |
| 86 | ||
| 87 | - **From this browser.** Placing an order appends its number to a signed | |
| 88 | `mine` cookie, at most ten deep. The cookie holds numbers and nothing else; | |
| 89 | the link is built from the access token read out of the row, so editing the | |
| 90 | cookie changes which number is being claimed and never what opens. A shared | |
| 91 | computer is not a shopper's, so **Forget these orders** drops the cookie. | |
| 92 | - **The form.** An order number and the email it was placed with. Both have to | |
| 93 | be right, the email is compared as a keyed digest so no letter of it times | |
| 94 | differently — and a number that matches nothing is digested against a fixed | |
| 95 | string, so a miss does the same work as a match — and a pair that does not | |
| 96 | match is refused in the same words whether or not the number exists. This | |
| 97 | form is not a way to learn which order numbers are real. It is rate limited | |
| 98 | like the other guessable things (see below), and a lookup that succeeds | |
| 99 | leaves a `looked-up` row in the order's events with the address it came | |
| 100 | from. | |
| 101 | ||
| 102 | ### What a rate limit is keyed on | |
| 103 | ||
| 104 | A limit only limits if the caller cannot choose which bucket it spends, so | |
| 105 | `src/peer.ts` decides where a request came from and a bucket is keyed on that | |
| 106 | and nothing else: the address the socket arrived on. `X-Forwarded-For` is not | |
| 107 | read. It would be the useful thing — it names the shopper behind a proxy — but | |
| 108 | only if a proxy we trust appends to it, and nothing this app can see from the | |
| 109 | inside proves that one does. A client can send that header itself, and a hint | |
| 110 | nobody vouches for is the caller's own word; keyed on the leftmost entry, as | |
| 111 | this app used to be, every bucket here was ten tries per invented header. | |
| 112 | ||
| 113 | The consequence is deliberate and worth knowing: behind the forge's proxy every | |
| 114 | shopper arrives on the same address, so these are the **shop's** limits rather | |
| 115 | than each shopper's, and the capacities in `src/ratelimit.ts` are set for a | |
| 116 | shop — 30 checkouts a minute, 30 codes every two minutes, 30 order lookups | |
| 117 | every five. Ordinary trade never meets them; guessing at a gift card code or | |
| 118 | an order number runs out of tries long before it runs out of guesses. If the | |
| 119 | forge ever says it appends a hop of its own, the rightmost entry becomes worth | |
| 120 | reading and `src/peer.ts` is the one file that changes. | |
| 121 | ||
| 122 | The address is checked for the shape of one before it is used, which is also | |
| 123 | why the `looked-up` note on an order can only ever hold an address and not a | |
| 124 | sentence somebody chose for an auditor to read. | |
| 125 | ||
| 78 | 126 | ## Payment |
| 79 | 127 | |
| 80 | 128 | | Environment | What happens | |
| @@ -162,10 +210,11 @@ for an admin. | ||
| 162 | 210 | | `src/routes.ts` | The table of paths | |
| 163 | 211 | | `src/db.ts` | The schema, opened once | |
| 164 | 212 | | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds | |
| 165 | | `src/cart.ts` `src/codes.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, integer cents, HMACs | | |
| 213 | | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the orders this browser placed, integer cents, HMACs | | |
| 166 | 214 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 167 | 215 | | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token | |
| 168 | | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox | | |
| 216 | | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it | | |
| 217 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | | |
| 169 | 218 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 170 | 219 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 171 | 220 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe | |
| @@ -175,6 +224,7 @@ for an admin. | ||
| 175 | 224 | The same shop over the same data. The exact command is in `FORGE_APP_SSH` |
| 176 | 225 | when the app runs; the footer of every page prints it verbatim, and says the |
| 177 | 226 | terminal is off when it is unset. `1/2/3` switch between stock, orders and the |
| 178 | outbox, `j`/`k` move, `s` moves stock with a reason, `r` refunds the rest of an | |
| 179 | order and puts it back on the shelf, `q` leaves. The first line says who the | |
| 180 | forge named at the keyboard; `s` and `r` are an admin's. | |
| 227 | outbox, `j`/`k` move, `s` moves stock with a reason, `/` finds an order by | |
| 228 | number, name or email — the terminal's half of `/orders` — `r` refunds the rest | |
| 229 | of an order and puts it back on the shelf, `q` leaves. The first line says who | |
| 230 | the forge named at the keyboard; `s` and `r` are an admin's. |
src/catalog.ts +44 −15
| @@ -36,38 +36,67 @@ export const productById = (id: number): ProductWithPrice | null => | ||
| 36 | 36 | ) |
| 37 | 37 | .get(id); |
| 38 | 38 | |
| 39 | export const products = (options: { category?: string; limit?: number; includeInactive?: boolean } = {}): ProductWithPrice[] => { | |
| 39 | type Listing = { category?: string; limit?: number; offset?: number; includeInactive?: boolean }; | |
| 40 | ||
| 41 | const listingWhere = (options: Listing): string => { | |
| 40 | 42 | const where = ["1 = 1"]; |
| 41 | 43 | if (!options.includeInactive) where.push("p.active = 1"); |
| 42 | 44 | if (options.category) where.push("c.slug = $category"); |
| 43 | return db | |
| 45 | return where.join(" AND "); | |
| 46 | }; | |
| 47 | ||
| 48 | export const products = (options: Listing = {}): ProductWithPrice[] => | |
| 49 | db | |
| 44 | 50 | .query<ProductWithPrice, any>( |
| 45 | 51 | `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id |
| 46 | WHERE ${where.join(" AND ")} ORDER BY p.title LIMIT $limit`, | |
| 52 | WHERE ${listingWhere(options)} ORDER BY p.title LIMIT $limit OFFSET $offset`, | |
| 47 | 53 | ) |
| 48 | .all({ ...(options.category ? { $category: options.category } : {}), $limit: options.limit ?? 60 }); | |
| 49 | }; | |
| 54 | .all({ | |
| 55 | ...(options.category ? { $category: options.category } : {}), | |
| 56 | $limit: options.limit ?? 60, | |
| 57 | $offset: options.offset ?? 0, | |
| 58 | }); | |
| 59 | ||
| 60 | /** How many there are in total, so a page can say whether there is another. */ | |
| 61 | export const countProducts = (options: Listing = {}): number => | |
| 62 | db | |
| 63 | .query<{ n: number }, any>( | |
| 64 | `SELECT COUNT(*) AS n FROM product p JOIN category c ON c.id = p.category_id WHERE ${listingWhere(options)}`, | |
| 65 | ) | |
| 66 | .get({ ...(options.category ? { $category: options.category } : {}) })!.n; | |
| 50 | 67 | |
| 51 | 68 | /** |
| 52 | 69 | * Search is a LIKE over title, blurb and sku. The needle is escaped so a |
| 53 | 70 | * shopper typing "50%" searches for "50%" and not for everything. |
| 54 | 71 | */ |
| 55 | export const search = (query: string, limit = 40): ProductWithPrice[] => { | |
| 56 | const needle = `%${query.trim().replace(/[\\%_]/g, (c) => "\\" + c)}%`; | |
| 57 | if (query.trim() === "") return []; | |
| 58 | return db | |
| 59 | .query<ProductWithPrice, [string, number]>( | |
| 60 | `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id | |
| 61 | WHERE p.active = 1 AND ( | |
| 72 | const MATCHES = `p.active = 1 AND ( | |
| 62 | 73 | p.title LIKE ?1 ESCAPE '\\' OR p.blurb LIKE ?1 ESCAPE '\\' OR p.description LIKE ?1 ESCAPE '\\' |
| 63 | 74 | OR c.name LIKE ?1 ESCAPE '\\' |
| 64 | 75 | OR EXISTS (SELECT 1 FROM variant v WHERE v.product_id = p.id AND v.sku LIKE ?1 ESCAPE '\\') |
| 65 | ) | |
| 66 | ORDER BY (p.title LIKE ?1 ESCAPE '\\') DESC, p.title LIMIT ?2`, | |
| 76 | )`; | |
| 77 | ||
| 78 | const needleFor = (query: string): string => `%${query.trim().replace(/[\\%_]/g, (c) => "\\" + c)}%`; | |
| 79 | ||
| 80 | export const search = (query: string, limit = 40, offset = 0): ProductWithPrice[] => { | |
| 81 | if (query.trim() === "") return []; | |
| 82 | return db | |
| 83 | .query<ProductWithPrice, [string, number, number]>( | |
| 84 | `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id | |
| 85 | WHERE ${MATCHES} | |
| 86 | ORDER BY (p.title LIKE ?1 ESCAPE '\\') DESC, p.title LIMIT ?2 OFFSET ?3`, | |
| 67 | 87 | ) |
| 68 | .all(needle, limit); | |
| 88 | .all(needleFor(query), limit, offset); | |
| 69 | 89 | }; |
| 70 | 90 | |
| 91 | export const countSearch = (query: string): number => | |
| 92 | query.trim() === "" | |
| 93 | ? 0 | |
| 94 | : db | |
| 95 | .query<{ n: number }, [string]>( | |
| 96 | `SELECT COUNT(*) AS n FROM product p JOIN category c ON c.id = p.category_id WHERE ${MATCHES}`, | |
| 97 | ) | |
| 98 | .get(needleFor(query))!.n; | |
| 99 | ||
| 71 | 100 | export const variantsOf = (productId: number, includeInactive = false): Variant[] => |
| 72 | 101 | db |
| 73 | 102 | .query<Variant, [number]>( |
src/context.ts +3 −1
| @@ -9,6 +9,7 @@ import { html } from "./http.ts"; | ||
| 9 | 9 | import { layout } from "./html.ts"; |
| 10 | 10 | import type { Applied } from "./codes.ts"; |
| 11 | 11 | import type { CartLine } from "./cart.ts"; |
| 12 | import type { Peer } from "./peer.ts"; | |
| 12 | 13 | import type { Session } from "./session.ts"; |
| 13 | 14 | import { returnTo } from "./who.ts"; |
| 14 | 15 | import type { Who } from "./who.ts"; |
| @@ -32,7 +33,8 @@ export type Ctx = { | ||
| 32 | 33 | setCookie: (value: string) => void; |
| 33 | 34 | /** True when the app is served over https, so cookies can be Secure. */ |
| 34 | 35 | secure: boolean; |
| 35 | ip: string; | |
| 36 | /** Where the request came from, as far as it can be proved: src/peer.ts. */ | |
| 37 | peer: Peer; | |
| 36 | 38 | }; |
| 37 | 39 | |
| 38 | 40 | export const cartCount = (cart: CartLine[]): number => cart.reduce((n, line) => n + line.quantity, 0); |
src/html.ts +2 −1
| @@ -90,7 +90,8 @@ export function layout(chrome: Chrome): string { | ||
| 90 | 90 | <footer class="bottom"><div class="wrap"> |
| 91 | 91 | <p><span class="env" style="background: ${env === "production" ? "#16653d" : "#1d4ed8"}">${escape(where)}</span> |
| 92 | 92 | ${commit ? `<span class="muted"> @ ${escape(commit)}</span>` : ""} |
| 93 | — a demonstration store: no goods ship and no money moves.</p> | |
| 93 | — a demonstration store: no goods ship and no money moves. | |
| 94 | <a href="/orders">Find an order</a></p> | |
| 94 | 95 | <p>${ |
| 95 | 96 | ssh |
| 96 | 97 | ? `Same shop in a terminal: <code>${escape(ssh)}</code>` |
src/mail.ts +1
| @@ -67,6 +67,7 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str | ||
| 67 | 67 | ? "a gift card, in full" |
| 68 | 68 | : "the mock gateway" |
| 69 | 69 | }.`, |
| 70 | `Lost the link to this order? The "Find an order" page takes ${order.number} with this email address.`, | |
| 70 | 71 | "This is a demonstration store: no goods ship and no money moves.", |
| 71 | 72 | ].join("\n"); |
| 72 | 73 | } |
src/mine.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * The orders this browser has placed, in a signed cookie beside the cart. | |
| 3 | * | |
| 4 | * Numbers and nothing else: the access token that actually opens an order is | |
| 5 | * read from the database on every render, so editing this cookie can only | |
| 6 | * change which order number is being claimed — and a number alone opens | |
| 7 | * nothing, because the link is built from the token in the row. Ten is as far | |
| 8 | * back as a cookie should remember; older orders are found with the form at | |
| 9 | * `/orders`, which asks for the email as well. | |
| 10 | */ | |
ping |
||
| 11 | ||
| 12 | import { sign, unsign } from "./sign.ts"; | |
| 13 | ||
| 14 | export const COOKIE = "mine"; | |
| 15 | export const KEEP = 10; | |
| 16 | ||
| 17 | /** The shape of an order number: "BZ-" and capitals or digits. */ | |
| 18 | const SHAPE = /^BZ-[A-Z0-9]{4,20}$/; | |
| 19 | export const looksLikeNumber = (value: string): boolean => SHAPE.test(value); | |
| 20 | ||
| 21 | export function encode(numbers: string[], secret: string): string { | |
| 22 | return sign(secret, numbers.filter(looksLikeNumber).slice(0, KEEP).join(",")); | |
| 23 | } | |
| 24 | ||
| 25 | export function decode(cookieValue: string | undefined, secret: string): string[] { | |
| 26 | if (!cookieValue) return []; | |
| 27 | const text = unsign(secret, cookieValue); | |
| 28 | if (text === undefined) return []; | |
| 29 | return text.split(",").filter(looksLikeNumber).slice(0, KEEP); | |
| 30 | } | |
| 31 | ||
| 32 | /** Newest first, each number once, never more than KEEP of them. */ | |
| 33 | export function remember(numbers: string[], number: string): string[] { | |
| 34 | if (!looksLikeNumber(number)) return numbers; | |
| 35 | return [number, ...numbers.filter((held) => held !== number)].slice(0, KEEP); | |
| 36 | } |
src/pages/checkout.ts +2 −1
| @@ -223,7 +223,8 @@ ${itemsTable(items)} | ||
| 223 | 223 | ${orderTotals(order, refunded)} |
| 224 | 224 | <h2>Shipping to</h2> |
| 225 | 225 | <p>${addressBlock(order)}<br><span class="muted">${escape(order.email)}</span></p> |
| 226 | <p class="muted">Keep this address: it is the only way back to this order. | |
| 226 | <p class="muted">Keep this address — it is what opens this order. If you lose it, | |
| 227 | <a href="/orders">find the order</a> again with its number and ${escape(order.email)}. | |
| 227 | 228 | ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p> |
| 228 | 229 | `; |
| 229 | 230 | } |
src/pages/find.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * "Find an order": the way back to an order once the link is gone. | |
| 3 | * | |
| 4 | * Two things are on this page — the orders this browser remembers placing, | |
| 5 | * and a form that asks for an order number and the email it was placed with. | |
| 6 | * Every link here carries the order's own access token, so the page that | |
| 7 | * opens is the same one the shopper was sent to at checkout. | |
| 8 | */ | |
| 9 | ||
| 10 | import { csrfField, escape } from "../html.ts"; | |
| 11 | import { formatCents } from "../money.ts"; | |
| 12 | import { statusPill } from "./checkout.ts"; | |
| 13 | import type { Order } from "../db.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | ||
| 16 | const row = (order: Order): string => `<tr> | |
| 17 | <td><a href="/orders/${escape(order.number)}?token=${escape(order.access_token)}">${escape(order.number)}</a></td> | |
| 18 | <td class="muted">${escape(order.created_at)} UTC</td> | |
| 19 | <td>${statusPill(order.status)}</td> | |
| 20 | <td class="num price">${escape(formatCents(order.total_cents))}</td> | |
| 21 | </tr>`; | |
| 22 | ||
| 23 | export function find( | |
| 24 | mine: Order[], | |
| 25 | session: Session, | |
| 26 | values: { number?: string; email?: string } = {}, | |
| 27 | error?: string, | |
| 28 | ): string { | |
| 29 | return ` | |
| 30 | <h1>Find an order</h1> | |
| 31 | <p class="lede">An order is private, so it opens with its own link. Type the order number and the | |
| 32 | email it was placed with and the link is handed back to you.</p> | |
| 33 | ${error ? `<p class="notice bad">${escape(error)}</p>` : ""} | |
| 34 | <form method="post" action="/orders/find" class="lookup"> | |
| 35 | ${csrfField(session)} | |
| 36 | <div> | |
| 37 | <label>Order number | |
| 38 | <input name="number" value="${escape(values.number ?? "")}" maxlength="20" required | |
| 39 | placeholder="BZ-XXXXXXXX" autocapitalize="characters" autocomplete="off"> | |
| 40 | </label> | |
| 41 | </div> | |
| 42 | <div> | |
| 43 | <label>Email | |
| 44 | <input name="email" type="email" value="${escape(values.email ?? "")}" maxlength="200" required | |
| 45 | autocomplete="email"> | |
| 46 | </label> | |
| 47 | <button class="btn" style="margin-top:.75rem">Find it</button> | |
| 48 | </div> | |
| 49 | </form> | |
| 50 | ${ | |
| 51 | mine.length === 0 | |
| 52 | ? `<p class="muted" style="margin-top:2rem">No orders have been placed from this browser.</p>` | |
| 53 | : `<h2 style="margin-top:2rem">From this browser</h2> | |
| 54 | <table> | |
| 55 | <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="num">Total</th></tr></thead> | |
| 56 | <tbody>${mine.map(row).join("\n")}</tbody> | |
| 57 | </table> | |
| 58 | <p class="muted">The last ${mine.length === 1 ? "order" : mine.length + " orders"} placed here, remembered in a | |
| 59 | signed cookie that holds order numbers and nothing else. On a shared computer, drop it:</p> | |
| 60 | <form method="post" action="/orders/forget"> | |
| 61 | ${csrfField(session)} | |
| 62 | <button class="btn ghost">Forget these orders</button> | |
| 63 | </form>` | |
| 64 | } | |
| 65 | `; | |
| 66 | } |
src/pages/storefront.ts +27 −4
| @@ -41,20 +41,43 @@ ${grid(featured.filter((product) => product.category_slug === category.slug))}`, | ||
| 41 | 41 | .join("\n")} |
| 42 | 42 | `; |
| 43 | 43 | |
| 44 | export const category = (found: Category, products: ProductWithPrice[]): string => ` | |
| 44 | /** Where a listing is in a longer list: "page 2 of 4", with the way either side. */ | |
| 45 | export type Page = { number: number; size: number; total: number }; | |
| 46 | ||
| 47 | export const pageCount = (page: Page): number => Math.max(1, Math.ceil(page.total / page.size)); | |
| 48 | ||
| 49 | export function pager(path: string, page: Page): string { | |
| 50 | const last = pageCount(page); | |
| 51 | if (last <= 1) return ""; | |
| 52 | const link = (to: number, label: string): string => | |
| 53 | to < 1 || to > last | |
| 54 | ? `<span class="btn ghost" aria-disabled="true">${escape(label)}</span>` | |
| 55 | : `<a class="btn ghost" href="${escape(path)}${path.includes("?") ? "&" : "?"}page=${to}">${escape(label)}</a>`; | |
| 56 | return `<nav class="row-actions" style="justify-content:center; margin-top:1.5rem" aria-label="Pages"> | |
| 57 | ${link(page.number - 1, "Previous")} | |
| 58 | <span class="muted">Page ${page.number} of ${last}</span> | |
| 59 | ${link(page.number + 1, "Next")} | |
| 60 | </nav>`; | |
| 61 | } | |
| 62 | ||
| 63 | export const category = (found: Category, products: ProductWithPrice[], page: Page): string => ` | |
| 45 | 64 | <h1>${escape(found.name)}</h1> |
| 46 | <p class="lede">${escape(found.blurb)}</p> | |
| 65 | <p class="lede">${escape(found.blurb)} <span class="muted">${page.total} ${ | |
| 66 | page.total === 1 ? "thing" : "things" | |
| 67 | }.</span></p> | |
| 47 | 68 | ${grid(products)} |
| 69 | ${pager(`/c/${encodeURIComponent(found.slug)}`, page)} | |
| 48 | 70 | `; |
| 49 | 71 | |
| 50 | export const search = (query: string, results: ProductWithPrice[]): string => ` | |
| 72 | export const search = (query: string, results: ProductWithPrice[], page: Page): string => ` | |
| 51 | 73 | <h1>${query ? `“${escape(query)}”` : "Search"}</h1> |
| 52 | 74 | <p class="lede">${ |
| 53 | 75 | query === "" |
| 54 | 76 | ? "Type something into the box above." |
| 55 | : `${results.length} ${results.length === 1 ? "match" : "matches"}.` | |
| 77 | : `${page.total} ${page.total === 1 ? "match" : "matches"}.` | |
| 56 | 78 | }</p> |
| 57 | 79 | ${query === "" ? "" : grid(results)} |
| 80 | ${query === "" ? "" : pager(`/search?q=${encodeURIComponent(query)}`, page)} | |
| 58 | 81 | `; |
| 59 | 82 | |
| 60 | 83 | export function product( |
src/peer.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * Where a request came from, told apart from where it says it came from. | |
| 3 | * | |
| 4 | * A rate limit is only a limit if the caller cannot pick which bucket it | |
| 5 | * spends, so a bucket is keyed on this and nothing else: the address the | |
| 6 | * socket actually arrived on, which is the one thing here that is not the | |
| 7 | * caller's to write. | |
| 8 | * | |
| 9 | * `X-Forwarded-For` is not read. It would be the useful thing — it names the | |
| 10 | * shopper behind a proxy — but only if a proxy we trust appends to it, and | |
| 11 | * nothing this app can see from the inside proves that one does. Anything a | |
| 12 | * client sends arrives in that header too, and a hint nobody vouches for is | |
| 13 | * the caller's own word: a limit keyed on the caller's own word is not a | |
| 14 | * limit, which is what the leftmost entry made of every bucket here before. | |
| 15 | * So it is dropped, and a bucket behind the forge's proxy is the whole shop's | |
| 16 | * — see the README. If the forge ever says it appends a hop of its own, the | |
| 17 | * rightmost entry becomes worth reading and this is the one file that changes. | |
| 18 | * | |
| 19 | * Nothing leaves here unchecked. The address keys a bucket and it is also | |
| 20 | * written into `order_event` detail, where an auditor reads it, so it has to | |
| 21 | * look like an address before either. | |
| 22 | */ | |
| 23 | ||
| 24 | export type Peer = { | |
| 25 | /** The socket's address. The caller cannot choose this one. */ | |
| 26 | address: string; | |
| 27 | }; | |
| 28 | ||
| 29 | /** The longest an address can be: an IPv6 one with an IPv4 tail inside it. */ | |
| 30 | export const MAX = 45; | |
| 31 | ||
| 32 | /** | |
| 33 | * The value as an address, or null. Four octets of at most 255, or hex digits | |
| 34 | * and colons; everything else — a hostname, a comma, a space, a word an | |
| 35 | * auditor would misread as a note — is not an address and is dropped. | |
| 36 | */ | |
| 37 | export function address(value: string | null | undefined): string | null { | |
| 38 | const text = (value ?? "").trim(); | |
| 39 | if (text.length === 0 || text.length > MAX) return null; | |
| 40 | if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) | |
| 41 | return text.split(".").every((octet) => Number(octet) <= 255) ? text : null; | |
| 42 | if (text.includes(":") && /^[0-9A-Fa-f:.]+$/.test(text)) return text; | |
| 43 | return null; | |
| 44 | } | |
| 45 | ||
| 46 | /** `socket` is the address the request actually arrived on, or nothing yet. */ | |
| 47 | export const peerOf = (socket: string | null | undefined): Peer => ({ address: address(socket) ?? "local" }); |
src/ratelimit.ts +21 −10
| @@ -1,23 +1,34 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Rate limits. A token bucket per key, in memory: this is one process, and a |
| 3 | * limit that resets when the app restarts is still a limit. Checkout, payment | |
| 4 | * the admin sign-in and typing a code each get their own bucket so a busy shop cannot lock | |
| 5 | * anyone out of the others. | |
| 3 | * limit that resets when the app restarts is still a limit. Checkout, payment, | |
| 4 | * typing a code and finding an order each get their own bucket so a busy shop | |
| 5 | * cannot lock anyone out of the others. | |
| 6 | * | |
| 7 | * A bucket is keyed on the peer's address and nothing else (see src/peer.ts): | |
| 8 | * on a header the caller writes it would be no limit at all. Behind the | |
| 9 | * forge's proxy that address is the proxy's, so these are the shop's limits | |
| 10 | * rather than each shopper's, and the capacities are set for a shop: enough | |
| 11 | * that ordinary trade never meets them, low enough that guessing at a code or | |
| 12 | * an order number runs out of tries long before it runs out of guesses. | |
| 6 | 13 | */ |
| 7 | 14 | |
| 15 | import type { Peer } from "./peer.ts"; | |
| 16 | ||
| 8 | 17 | type Bucket = { tokens: number; at: number }; |
| 9 | 18 | const buckets = new Map<string, Bucket>(); |
| 10 | 19 | |
| 11 | 20 | export type Limit = { name: string; capacity: number; perSeconds: number }; |
| 12 | 21 | |
| 13 | export const CHECKOUT: Limit = { name: "checkout", capacity: 8, perSeconds: 60 }; | |
| 14 | export const PAYMENT: Limit = { name: "payment", capacity: 20, perSeconds: 60 }; | |
| 22 | export const CHECKOUT: Limit = { name: "checkout", capacity: 30, perSeconds: 60 }; | |
| 23 | export const PAYMENT: Limit = { name: "payment", capacity: 60, perSeconds: 60 }; | |
| 15 | 24 | // Codes are guessable in principle, so trying them is the slowest thing here. |
| 16 | export const CODES: Limit = { name: "codes", capacity: 10, perSeconds: 120 }; | |
| 25 | export const CODES: Limit = { name: "codes", capacity: 30, perSeconds: 120 }; | |
| 26 | // So is an order number with an email beside it: a few tries, then wait. | |
| 27 | export const LOOKUP: Limit = { name: "lookup", capacity: 30, perSeconds: 300 }; | |
| 17 | 28 | |
| 18 | 29 | /** True when the caller may proceed; false when the bucket is empty. */ |
| 19 | export function take(limit: Limit, who: string, now = Date.now()): boolean { | |
| 20 | const key = `${limit.name}:${who}`; | |
| 30 | export function take(limit: Limit, peer: Peer, now = Date.now()): boolean { | |
| 31 | const key = `${limit.name}:${peer.address}`; | |
| 21 | 32 | const rate = limit.capacity / (limit.perSeconds * 1000); |
| 22 | 33 | const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now }; |
| 23 | 34 | const refilled = Math.min(limit.capacity, bucket.tokens + (now - bucket.at) * rate); |
| @@ -31,8 +42,8 @@ export function take(limit: Limit, who: string, now = Date.now()): boolean { | ||
| 31 | 42 | } |
| 32 | 43 | |
| 33 | 44 | /** Seconds until one token is back, for Retry-After. */ |
| 34 | export function retryAfter(limit: Limit, who: string, now = Date.now()): number { | |
| 35 | const bucket = buckets.get(`${limit.name}:${who}`); | |
| 45 | export function retryAfter(limit: Limit, peer: Peer, now = Date.now()): number { | |
| 46 | const bucket = buckets.get(`${limit.name}:${peer.address}`); | |
| 36 | 47 | if (!bucket) return 0; |
| 37 | 48 | const rate = limit.capacity / (limit.perSeconds * 1000); |
| 38 | 49 | const refilled = Math.min(limit.capacity, bucket.tokens + (now - bucket.at) * rate); |
src/routes.ts +3
| @@ -37,6 +37,9 @@ export const routes: Route[] = [ | ||
| 37 | 37 | { method: "POST", pattern: "/pay/:number/stripe", handler: shop.payStripe }, |
| 38 | 38 | { method: "POST", pattern: "/webhooks/stripe", handler: shop.stripeWebhook, csrf: false, raw: true }, |
| 39 | 39 | |
| 40 | { method: "GET", pattern: "/orders", handler: (ctx) => shop.findForm(ctx) }, | |
| 41 | { method: "POST", pattern: "/orders/find", handler: shop.findOrder }, | |
| 42 | { method: "POST", pattern: "/orders/forget", handler: shop.forgetOrders }, | |
| 40 | 43 | { method: "GET", pattern: "/orders/:number", handler: shop.orderPage }, |
| 41 | 44 | { method: "GET", pattern: "/orders/:number/receipt", handler: shop.receiptPage }, |
| 42 | 45 |
src/server.test.ts +169 −1
| @@ -60,7 +60,12 @@ class Shopper { | ||
| 60 | 60 | return (await this.get(path)).text(); |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | async post(path: string, fields: Record<string, string>, csrf?: string): Promise<Response> { | |
| 63 | async post( | |
| 64 | path: string, | |
| 65 | fields: Record<string, string>, | |
| 66 | csrf?: string, | |
| 67 | extra: Record<string, string> = {}, | |
| 68 | ): Promise<Response> { | |
| 64 | 69 | const body = new URLSearchParams(fields); |
| 65 | 70 | if (csrf !== undefined) body.set("csrf", csrf); |
| 66 | 71 | const response = await fetch(base + path, { |
| @@ -71,6 +76,7 @@ class Shopper { | ||
| 71 | 76 | "sec-fetch-site": "same-origin", |
| 72 | 77 | "content-type": "application/x-www-form-urlencoded", |
| 73 | 78 | ...this.forge(), |
| 79 | ...extra, | |
| 74 | 80 | }, |
| 75 | 81 | redirect: "manual", |
| 76 | 82 | }); |
| @@ -748,3 +754,165 @@ test("an admin adds a product and a variant, and the shop sells it", async () => | ||
| 748 | 754 | expect(shop).toContain("KIT-SPOON-1"); |
| 749 | 755 | expect(stockOf("KIT-SPOON-1")).toBe(5); |
| 750 | 756 | }); |
| 757 | ||
| 758 | test("an order is found again with its number and the email it was placed with", async () => { | |
| 759 | limit.forget(); | |
| 760 | const shopper = new Shopper(); | |
| 761 | const { number, token } = await buy(shopper, "COF-GUJI-250", 1); | |
| 762 | ||
| 763 | // Another browser knows nothing until it names both the order and the email. | |
| 764 | const stranger = new Shopper(); | |
| 765 | expect((await stranger.get(`/orders/${number}`)).status).toBe(404); | |
| 766 | const form = await stranger.page("/orders"); | |
| 767 | const wrong = await stranger.post( | |
| 768 | "/orders/find", | |
| 769 | { number, email: "[email protected]" }, | |
| 770 | Shopper.tokenIn(form), | |
| 771 | ); | |
| 772 | expect(wrong.status).toBe(200); | |
| 773 | expect(await wrong.text()).toContain("No order here matches"); | |
| 774 | ||
| 775 | const found = await stranger.post("/orders/find", { number, email: "[email protected]" }, Shopper.tokenIn(form)); | |
| 776 | expect(found.status).toBe(303); | |
| 777 | const where = orderAt(found.headers.get("location")!); | |
| 778 | expect(where.number).toBe(number); | |
| 779 | expect(where.token).toBe(token); | |
| 780 | expect(await stranger.page(found.headers.get("location")!)).toContain(number); | |
| 781 | limit.forget(); | |
| 782 | }); | |
| 783 | ||
| 784 | test("a number that was never issued is refused in the same words as a wrong email", async () => { | |
| 785 | limit.forget(); | |
| 786 | const shopper = new Shopper(); | |
| 787 | const form = await shopper.page("/orders"); | |
| 788 | const refusal = async (fields: Record<string, string>): Promise<string> => | |
| 789 | (await shopper.post("/orders/find", fields, Shopper.tokenIn(form))).text(); | |
| 790 | const invented = await refusal({ number: "BZ-ZZZZZZZZ", email: "[email protected]" }); | |
| 791 | const { number } = await buy(shopper, "COF-HOUSE-250", 1); | |
| 792 | const mismatched = await refusal({ number, email: "[email protected]" }); | |
| 793 | expect(invented).toContain("No order here matches"); | |
| 794 | expect(mismatched).toContain("No order here matches"); | |
| 795 | limit.forget(); | |
| 796 | }); | |
| 797 | ||
| 798 | test("looking an order up is rate limited", async () => { | |
| 799 | limit.forget(); | |
| 800 | const shopper = new Shopper(); | |
| 801 | const form = await shopper.page("/orders"); | |
| 802 | const token = Shopper.tokenIn(form); | |
| 803 | let sawLimit = false; | |
| 804 | for (let attempt = 0; attempt < limit.LOOKUP.capacity + 2; attempt++) { | |
| 805 | const response = await shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token); | |
| 806 | if (response.status === 429) { | |
| 807 | sawLimit = true; | |
| 808 | expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 809 | break; | |
| 810 | } | |
| 811 | } | |
| 812 | expect(sawLimit).toBe(true); | |
| 813 | limit.forget(); | |
| 814 | }); | |
| 815 | ||
| 816 | test("a spent limit cannot be refilled by changing x-forwarded-for", async () => { | |
| 817 | limit.forget(); | |
| 818 | const shopper = new Shopper(); | |
| 819 | const token = Shopper.tokenIn(await shopper.page("/orders")); | |
| 820 | const guess = (from: string): Promise<Response> => | |
| 821 | shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token, { | |
| 822 | "x-forwarded-for": from, | |
| 823 | }); | |
| 824 | ||
| 825 | // Spend the bucket claiming to be one address... | |
| 826 | for (let attempt = 0; attempt < limit.LOOKUP.capacity + 2; attempt++) await guess("203.0.113.9"); | |
| 827 | // ...then claim to be another, and a third, and send no claim at all. | |
| 828 | expect((await guess("198.51.100.77")).status).toBe(429); | |
| 829 | expect((await guess("2001:db8::1")).status).toBe(429); | |
| 830 | expect( | |
| 831 | (await shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token)).status, | |
| 832 | ).toBe(429); | |
| 833 | limit.forget(); | |
| 834 | }); | |
| 835 | ||
| 836 | test("the orders a browser placed are listed at /orders, and only for that browser", async () => { | |
| 837 | limit.forget(); | |
| 838 | const shopper = new Shopper(); | |
| 839 | const { number, token } = await buy(shopper, "COF-GUJI-250", 1); | |
| 840 | const mine = await shopper.page("/orders"); | |
| 841 | expect(mine).toContain(number); | |
| 842 | expect(mine).toContain(`/orders/${number}?token=${token}`); | |
| 843 | expect(await new Shopper().page("/orders")).not.toContain(number); | |
| 844 | limit.forget(); | |
| 845 | }); | |
| 846 | ||
| 847 | test("the order-lookup form has a class of its own, not the header search's", async () => { | |
| 848 | const page = await new Shopper().page("/orders"); | |
| 849 | expect(page).toContain(`action="/orders/find" class="lookup"`); | |
| 850 | // `find` is the search box in the header, and it is the only one on the page. | |
| 851 | expect(page.match(/class="find"/g)?.length).toBe(1); | |
| 852 | }); | |
| 853 | ||
| 854 | test("a shared browser can forget the orders it remembers", async () => { | |
| 855 | limit.forget(); | |
| 856 | const shopper = new Shopper(); | |
| 857 | const { number } = await buy(shopper, "COF-GUJI-250", 1); | |
| 858 | const listed = await shopper.page("/orders"); | |
| 859 | expect(listed).toContain(number); | |
| 860 | ||
| 861 | const forgotten = await shopper.post("/orders/forget", {}, Shopper.tokenIn(listed)); | |
| 862 | expect(forgotten.status).toBe(303); | |
| 863 | const after = await shopper.page("/orders"); | |
| 864 | expect(after).not.toContain(number); | |
| 865 | expect(after).toContain("No orders have been placed from this browser"); | |
| 866 | // Forgetting is a POST from this page and nowhere else. | |
| 867 | expect((await shopper.post("/orders/forget", {})).status).toBe(403); | |
| 868 | limit.forget(); | |
| 869 | }); | |
| 870 | ||
| 871 | test("a variant that is not the named product's own is not added to the cart", async () => { | |
| 872 | const shopper = new Shopper(); | |
| 873 | const product = await shopper.page("/p/stoneware-mug"); | |
| 874 | const refused = await shopper.post( | |
| 875 | "/cart/add", | |
| 876 | { variant: String(variantId("COF-HOUSE-250")), quantity: "1", slug: "stoneware-mug" }, | |
| 877 | Shopper.tokenIn(product), | |
| 878 | ); | |
| 879 | expect(refused.status).toBe(303); | |
| 880 | expect(refused.headers.get("location")).toBe("/p/stoneware-mug"); | |
| 881 | expect(await shopper.page("/cart")).not.toContain("House Espresso"); | |
| 882 | }); | |
| 883 | ||
| 884 | test("a category longer than one page is paged, and every page is reachable", async () => { | |
| 885 | const catalog = await import("./catalog.ts"); | |
| 886 | const categoryId = db | |
| 887 | .query<{ id: number }, []>( | |
| 888 | "INSERT INTO category (slug, name, blurb) VALUES ('bulk', 'Bulk', 'Many of a muchness.') RETURNING id", | |
| 889 | ) | |
| 890 | .get()!.id; | |
| 891 | for (let i = 1; i <= 26; i++) | |
| 892 | catalog.createProduct({ | |
| 893 | title: `Bulk Item ${String(i).padStart(2, "0")}`, | |
| 894 | slug: `bulk-item-${i}`, | |
| 895 | blurb: "One of many.", | |
| 896 | description: "", | |
| 897 | category_id: categoryId, | |
| 898 | active: true, | |
| 899 | }); | |
| 900 | ||
| 901 | const first = await fetch(base + "/c/bulk").then((r) => r.text()); | |
| 902 | expect(first).toContain("Page 1 of 2"); | |
| 903 | expect(first).toContain("Bulk Item 01"); | |
| 904 | expect(first).not.toContain("Bulk Item 26"); | |
| 905 | const second = await fetch(base + "/c/bulk?page=2").then((r) => r.text()); | |
| 906 | expect(second).toContain("Page 2 of 2"); | |
| 907 | expect(second).toContain("Bulk Item 26"); | |
| 908 | expect(second).not.toContain("Bulk Item 01"); | |
| 909 | ||
| 910 | // A page past the end is the last page; a page that is not a number is the first. | |
| 911 | expect(await fetch(base + "/c/bulk?page=99").then((r) => r.text())).toContain("Page 2 of 2"); | |
| 912 | expect(await fetch(base + "/c/bulk?page=not-a-number").then((r) => r.text())).toContain("Page 1 of 2"); | |
| 913 | ||
| 914 | const found = await fetch(base + "/search?q=Bulk+Item").then((r) => r.text()); | |
| 915 | expect(found).toContain("26 matches"); | |
| 916 | expect(found).toContain("Page 1 of 2"); | |
| 917 | expect(await fetch(base + "/search?q=Bulk+Item&page=2").then((r) => r.text())).toContain("Bulk Item 26"); | |
| 918 | }); |
src/server.ts +3 −2
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | import { loadSecret } from "./sign.ts"; |
| 26 | 26 | import { match } from "./routes.ts"; |
| 27 | 27 | import { notFound } from "./shop.ts"; |
| 28 | import { peerOf } from "./peer.ts"; | |
| 28 | 29 | import { seed } from "./catalog.ts"; |
| 29 | 30 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 30 | 31 | import { seed as seedTax } from "./tax.ts"; |
| @@ -162,7 +163,6 @@ function context( | ||
| 162 | 163 | outgoing: string[], |
| 163 | 164 | self: { requestIP(request: Request): { address: string } | null }, |
| 164 | 165 | ): Ctx { |
| 165 | const forwarded = (request.headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim(); | |
| 166 | 166 | return { |
| 167 | 167 | request, |
| 168 | 168 | url, |
| @@ -176,7 +176,8 @@ function context( | ||
| 176 | 176 | cookies, |
| 177 | 177 | setCookie: (value: string) => outgoing.push(value), |
| 178 | 178 | secure: publicOrigin.startsWith("https:"), |
| 179 | ip: forwarded || self.requestIP(request)?.address || "local", | |
| 179 | // The socket, never a header: see src/peer.ts for why nothing else counts. | |
| 180 | peer: peerOf(self.requestIP(request)?.address), | |
| 180 | 181 | }; |
| 181 | 182 | } |
| 182 | 183 |
src/shop.ts +111 −14
| @@ -13,15 +13,27 @@ import * as discounts from "./discounts.ts"; | ||
| 13 | 13 | import * as gateway from "./gateway.ts"; |
| 14 | 14 | import * as giftcards from "./giftcards.ts"; |
| 15 | 15 | import * as limit from "./ratelimit.ts"; |
| 16 | import * as mine from "./mine.ts"; | |
| 16 | 17 | import * as orders from "./orders.ts"; |
| 17 | 18 | import * as pages from "./pages/checkout.ts"; |
| 18 | 19 | import * as storefront from "./pages/storefront.ts"; |
| 19 | import { categories, categoryBySlug, productBySlug, products, search, variantById, variantsOf } from "./catalog.ts"; | |
| 20 | import { | |
| 21 | categories, | |
| 22 | categoryBySlug, | |
| 23 | countProducts, | |
| 24 | countSearch, | |
| 25 | productBySlug, | |
| 26 | products, | |
| 27 | search, | |
| 28 | variantById, | |
| 29 | variantsOf, | |
| 30 | } from "./catalog.ts"; | |
| 20 | 31 | import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts"; |
| 32 | import { find as findPage } from "./pages/find.ts"; | |
| 21 | 33 | import { render, type Ctx } from "./context.ts"; |
| 22 | 34 | import { page as cartPage } from "./pages/cart.ts"; |
| 23 | 35 | import { chosen } from "./gateway.ts"; |
| 24 | import { equals, sign, unsign } from "./sign.ts"; | |
| 36 | import { equals, hmac, sign, unsign } from "./sign.ts"; | |
| 25 | 37 | import { forOrder } from "./mail.ts"; |
| 26 | 38 | import { dueCents, parseQuantity } from "./money.ts"; |
| 27 | 39 | import { escape } from "./html.ts"; |
| @@ -44,24 +56,52 @@ const saveCodes = (ctx: Ctx, applied: codes.Applied): void => { | ||
| 44 | 56 | ctx.codes = applied; |
| 45 | 57 | }; |
| 46 | 58 | |
| 59 | /** The order numbers this browser has placed, so `/orders` can list them. */ | |
| 60 | const myNumbers = (ctx: Ctx): string[] => mine.decode(ctx.cookies[mine.COOKIE], ctx.secret); | |
| 61 | ||
| 62 | const rememberOrder = (ctx: Ctx, number: string): void => { | |
| 63 | ctx.setCookie(jar(ctx, mine.COOKIE, mine.encode(mine.remember(myNumbers(ctx), number), ctx.secret))); | |
| 64 | }; | |
| 65 | ||
| 66 | /** A shared browser should be able to stop being this shopper's. */ | |
| 67 | export function forgetOrders(ctx: Ctx): Response { | |
| 68 | ctx.setCookie(`${mine.COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${ctx.secure ? "; Secure" : ""}`); | |
| 69 | return redirect("/orders"); | |
| 70 | } | |
| 71 | ||
| 47 | 72 | // --- browsing --------------------------------------------------------------- |
| 48 | 73 | |
| 49 | 74 | export const home = (ctx: Ctx): Response => |
| 50 | 75 | render(ctx, { title: "Shop" }, storefront.home(categories(), products({ limit: 60 }))); |
| 51 | 76 | |
| 77 | /** A listing is this long; the rest is a page away, never a page that never ends. */ | |
| 78 | const PAGE_SIZE = 24; | |
| 79 | ||
| 80 | /** "?page=3", clamped: a page number is 1..last and anything else is page 1. */ | |
| 81 | const pageIn = (ctx: Ctx, total: number): storefront.Page => { | |
| 82 | const asked = Number(ctx.url.searchParams.get("page") ?? "1"); | |
| 83 | const last = Math.max(1, Math.ceil(total / PAGE_SIZE)); | |
| 84 | const number = Number.isInteger(asked) && asked >= 1 ? Math.min(asked, last) : 1; | |
| 85 | return { number, size: PAGE_SIZE, total }; | |
| 86 | }; | |
| 87 | ||
| 52 | 88 | export function category(ctx: Ctx): Response { |
| 53 | 89 | const found = categoryBySlug(ctx.params.slug ?? ""); |
| 54 | 90 | if (!found) return notFound(ctx); |
| 55 | return render( | |
| 56 | ctx, | |
| 57 | { title: found.name, current: `c:${found.slug}` }, | |
| 58 | storefront.category(found, products({ category: found.slug })), | |
| 59 | ); | |
| 91 | const page = pageIn(ctx, countProducts({ category: found.slug })); | |
| 92 | const listing = products({ category: found.slug, limit: page.size, offset: (page.number - 1) * page.size }); | |
| 93 | return render(ctx, { title: found.name, current: `c:${found.slug}` }, storefront.category(found, listing, page)); | |
| 60 | 94 | } |
| 61 | 95 | |
| 62 | 96 | export function searchPage(ctx: Ctx): Response { |
| 63 | 97 | const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 80).trim(); |
| 64 | return render(ctx, { title: query ? `Search: ${query}` : "Search", query }, storefront.search(query, search(query))); | |
| 98 | const page = pageIn(ctx, countSearch(query)); | |
| 99 | const results = search(query, page.size, (page.number - 1) * page.size); | |
| 100 | return render( | |
| 101 | ctx, | |
| 102 | { title: query ? `Search: ${query}` : "Search", query }, | |
| 103 | storefront.search(query, results, page), | |
| 104 | ); | |
| 65 | 105 | } |
| 66 | 106 | |
| 67 | 107 | export function product(ctx: Ctx): Response { |
| @@ -89,7 +129,12 @@ export function addToCart(ctx: Ctx): Response { | ||
| 89 | 129 | const quantity = parseQuantity(ctx.form?.get("quantity"), 99); |
| 90 | 130 | const slug = field(ctx.form, "slug", 60); |
| 91 | 131 | const variant = variantId === undefined ? null : variantById(variantId); |
| 92 | if (!variant || variant.active !== 1 || quantity < 1) return redirect(slug ? `/p/${encodeURIComponent(slug)}` : "/"); | |
| 132 | // When the form names a product, the variant has to be one of that | |
| 133 | // product's own: a pair that does not agree did not come from that form. | |
| 134 | const named = slug === "" || !variant ? null : productBySlug(slug); | |
| 135 | const mismatched = named !== null && named.id !== variant!.product_id; | |
| 136 | if (!variant || variant.active !== 1 || quantity < 1 || mismatched) | |
| 137 | return redirect(slug ? `/p/${encodeURIComponent(slug)}` : "/"); | |
| 93 | 138 | saveCart(ctx, cart.add(ctx.cart, variant.id, quantity)); |
| 94 | 139 | return redirect("/cart"); |
| 95 | 140 | } |
| @@ -119,7 +164,7 @@ export function cartView(ctx: Ctx, error?: string): Response { | ||
| 119 | 164 | * neither is believed further than its own row in the database. |
| 120 | 165 | */ |
| 121 | 166 | export function applyCode(ctx: Ctx): Response { |
| 122 | if (!limit.take(limit.CODES, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.CODES, ctx.ip), "code"); | |
| 167 | if (!limit.take(limit.CODES, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.CODES, ctx.peer), "code"); | |
| 123 | 168 | const typed = field(ctx.form, "code", 40); |
| 124 | 169 | const back = field(ctx.form, "back", 20) === "checkout" ? "/checkout" : "/cart"; |
| 125 | 170 | if (typed === "") return redirect(back); |
| @@ -173,8 +218,8 @@ export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, st | ||
| 173 | 218 | } |
| 174 | 219 | |
| 175 | 220 | export function placeOrder(ctx: Ctx): Response { |
| 176 | if (!limit.take(limit.CHECKOUT, ctx.ip)) | |
| 177 | return tooMany(ctx, limit.retryAfter(limit.CHECKOUT, ctx.ip), "checkout"); | |
| 221 | if (!limit.take(limit.CHECKOUT, ctx.peer)) | |
| 222 | return tooMany(ctx, limit.retryAfter(limit.CHECKOUT, ctx.peer), "checkout"); | |
| 178 | 223 | const values = { |
| 179 | 224 | email: field(ctx.form, "email", 200), |
| 180 | 225 | name: field(ctx.form, "name", 120), |
| @@ -208,6 +253,7 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 208 | 253 | if (!result.ok) return checkoutForm(ctx, result.error, values); |
| 209 | 254 | saveCart(ctx, []); |
| 210 | 255 | saveCodes(ctx, codes.NONE); |
| 256 | rememberOrder(ctx, result.order.number); | |
| 211 | 257 | const where = dueCents(result.order) === 0 ? "orders" : "pay"; |
| 212 | 258 | return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`); |
| 213 | 259 | } |
| @@ -239,7 +285,7 @@ export function payPage(ctx: Ctx, error?: string): Response { | ||
| 239 | 285 | |
| 240 | 286 | /** The mock gateway: it answers whatever the button asked it to answer. */ |
| 241 | 287 | export function payMock(ctx: Ctx): Response { |
| 242 | if (!limit.take(limit.PAYMENT, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.ip), "payment"); | |
| 288 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); | |
| 243 | 289 | const order = openOrder(ctx, "form"); |
| 244 | 290 | if (!order) return notFound(ctx); |
| 245 | 291 | if (order.status !== "pending_payment") |
| @@ -268,7 +314,7 @@ const payPageFor = (ctx: Ctx, order: Order, error: string): Response => | ||
| 268 | 314 | |
| 269 | 315 | /** Stripe: build a Checkout Session and hand the browser to it. */ |
| 270 | 316 | export async function payStripe(ctx: Ctx): Promise<Response> { |
| 271 | if (!limit.take(limit.PAYMENT, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.ip), "payment"); | |
| 317 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); | |
| 272 | 318 | const order = openOrder(ctx, "form"); |
| 273 | 319 | if (!order) return notFound(ctx); |
| 274 | 320 | if (order.status !== "pending_payment") |
| @@ -301,6 +347,57 @@ export async function orderPage(ctx: Ctx): Promise<Response> { | ||
| 301 | 347 | ); |
| 302 | 348 | } |
| 303 | 349 | |
| 350 | /** | |
| 351 | * "Find an order". The orders this browser remembers are listed with their | |
| 352 | * own links; anything older is found by naming the order and the email it was | |
| 353 | * placed with. | |
| 354 | */ | |
| 355 | export function findForm( | |
| 356 | ctx: Ctx, | |
| 357 | error?: string, | |
| 358 | values: { number?: string; email?: string } = {}, | |
| 359 | ): Response { | |
| 360 | const held = myNumbers(ctx) | |
| 361 | .map((number) => orders.byNumber(number)) | |
| 362 | .filter((order): order is Order => order !== null); | |
| 363 | return render(ctx, { title: "Find an order" }, findPage(held, ctx.session, values, error)); | |
| 364 | } | |
| 365 | ||
| 366 | /** | |
| 367 | * The number and the email must both be right, and a wrong pair is refused in | |
| 368 | * the same words whether or not the order exists: this form must not be a way | |
| 369 | * to learn which order numbers are real. The email is compared as a keyed | |
| 370 | * digest, so neither its length nor its first wrong letter times differently, | |
| 371 | * and a number that matches nothing is digested against a fixed string so | |
| 372 | * that both paths do the same work as well as say the same thing. | |
| 373 | */ | |
| 374 | /** | |
| 375 | * Stands in for the email of an order that is not there. The email on the form | |
| 376 | * has already been checked for an `@`, so nothing a shopper can type digests | |
| 377 | * to this — it is only here so a miss does the same work as a match. | |
| 378 | */ | |
| 379 | const NO_SUCH_EMAIL = "no-such-order"; | |
| 380 | ||
| 381 | export function findOrder(ctx: Ctx): Response { | |
| 382 | if (!limit.take(limit.LOOKUP, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.LOOKUP, ctx.peer), "lookup"); | |
| 383 | const number = field(ctx.form, "number", 20).toUpperCase(); | |
| 384 | const email = field(ctx.form, "email", 200); | |
| 385 | const values = { number, email }; | |
| 386 | if (!mine.looksLikeNumber(number) || !looksLikeEmail(email)) | |
| 387 | return findForm(ctx, "An order number looks like BZ-XXXXXXXX, with the email it was placed with.", values); | |
| 388 | ||
| 389 | const order = orders.byNumber(number); | |
| 390 | const digest = (value: string): string => hmac(ctx.secret, `order-email:${value.trim().toLowerCase()}`); | |
| 391 | const matched = equals(digest(email), digest(order?.email ?? NO_SUCH_EMAIL)); | |
| 392 | if (!order || !matched) | |
| 393 | return findForm(ctx, "No order here matches that number and email address.", values); | |
| 394 | ||
| 395 | // The address, not the header: what goes in the trail has to be a fact. | |
| 396 | orders.note(order.id, "looked-up", `found from ${ctx.peer.address}`); | |
| 397 | rememberOrder(ctx, order.number); | |
| 398 | return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`); | |
| 399 | } | |
| 400 | ||
| 304 | 401 | export async function receiptPage(ctx: Ctx): Promise<Response> { |
| 305 | 402 | const order = openOrder(ctx, "query"); |
| 306 | 403 | if (!order) return notFound(ctx); |
src/style.ts +2 −1
| @@ -59,6 +59,7 @@ p.lede { color: var(--dim); margin: 0 0 1.5rem; } | ||
| 59 | 59 | .pill.warn { color: var(--warn); border-color: currentColor; } |
| 60 | 60 | .product { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; align-items: start; } |
| 61 | 61 | .product .art { border-radius: 1rem; aspect-ratio: 1; font-size: 3rem; } |
| 62 | .lookup { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; align-items: end; } | |
| 62 | 63 | .notice { border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: .5rem; padding: .7rem .9rem; margin: 0 0 1.25rem; background: #8881; } |
| 63 | 64 | .notice.bad { border-left-color: var(--warn); } |
| 64 | 65 | table { width: 100%; border-collapse: collapse; font-size: .95rem; } |
| @@ -82,7 +83,7 @@ footer.bottom code { background: #8881; padding: .1rem .35rem; border-radius: .3 | ||
| 82 | 83 | .admin nav { display: flex; gap: 1rem; font-size: .95rem; border-bottom: 1px solid var(--line); padding-bottom: .75rem; margin-bottom: 1.5rem; flex-wrap: wrap; } |
| 83 | 84 | .stack { display: grid; gap: .9rem; max-width: 34rem; } |
| 84 | 85 | .row-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } |
| 85 | @media (max-width: 46rem) { .product { grid-template-columns: 1fr; } .fields { grid-template-columns: 1fr; } } | |
| 86 | @media (max-width: 46rem) { .product, .lookup { grid-template-columns: 1fr; } .fields { grid-template-columns: 1fr; } } | |
| 86 | 87 | `.trim(); |
| 87 | 88 | |
| 88 | 89 | export const CSS_PATH = `/static/bazaar.${createHash("sha256").update(CSS).digest("hex").slice(0, 8)}.css`; |
src/tty.ts +22 −5
| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | * stock and refunding are an admin's to do, here as on the web. |
| 9 | 9 | * |
| 10 | 10 | * 1/2/3 switch between stock, orders and the outbox. j/k move, s moves stock |
| 11 | * with a reason, r refunds what is left of an order and puts it back on the | |
| 12 | * shelf, q leaves. Plain ANSI, no dependencies. | |
| 11 | * with a reason, / finds an order by number, name or email, r refunds what is | |
| 12 | * left of an order and puts it back on the shelf, q leaves. Plain ANSI, no | |
| 13 | * dependencies. | |
| 13 | 14 | */ |
| 14 | 15 | |
| 15 | 16 | import { db } from "./db.ts"; |
| @@ -56,8 +57,13 @@ let tab: "stock" | "orders" | "outbox" = "stock"; | ||
| 56 | 57 | let cursor = 0; |
| 57 | 58 | let prompt: { label: string; text: string; run: (value: string) => string } | undefined; |
| 58 | 59 | let message = ""; |
| 60 | // What `/` is narrowing the orders tab to: a number, a name or an email, | |
| 61 | // the same needle /admin/orders takes. | |
| 62 | let filter = ""; | |
| 59 | 63 | |
| 60 | const rowsFor = (): unknown[] => (tab === "stock" ? stockRows() : tab === "orders" ? recent(100) : outbox(50)); | |
| 64 | const orderRows = (): Order[] => recent(100, filter); | |
| 65 | ||
| 66 | const rowsFor = (): unknown[] => (tab === "stock" ? stockRows() : tab === "orders" ? orderRows() : outbox(50)); | |
| 61 | 67 | |
| 62 | 68 | function detail(order: Order): string[] { |
| 63 | 69 | const lines = [""]; |
| @@ -118,7 +124,7 @@ function draw(): void { | ||
| 118 | 124 | tab === "stock" |
| 119 | 125 | ? `1/2/3 tabs · j/k move${isAdmin ? " · s move stock" : ""} · h ledger · q quit` |
| 120 | 126 | : tab === "orders" |
| 121 | ? `1/2/3 tabs · j/k move${isAdmin ? " · r refund the rest and restock" : ""} · q quit` | |
| 127 | ? `1/2/3 tabs · j/k move · / find${filter ? ` (“${filter}”)` : ""}${isAdmin ? " · r refund the rest and restock" : ""} · q quit` | |
| 122 | 128 | : "1/2/3 tabs · j/k move · q quit", |
| 123 | 129 | ) |
| 124 | 130 | : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`, |
| @@ -141,8 +147,17 @@ function moveStock(value: string): string { | ||
| 141 | 147 | return `${row.sku}: ${delta > 0 ? "+" : ""}${delta} — ${reason}`; |
| 142 | 148 | } |
| 143 | 149 | |
| 150 | /** `/` on the orders tab: the needle stays until it is cleared with an empty one. */ | |
| 151 | function findOrders(value: string): string { | |
| 152 | filter = value.trim().slice(0, 60); | |
| 153 | cursor = 0; | |
| 154 | const found = orderRows().length; | |
| 155 | if (filter === "") return "Showing every order."; | |
| 156 | return found === 0 ? `Nothing matches “${filter}”.` : `${found} order${found === 1 ? "" : "s"} match “${filter}”.`; | |
| 157 | } | |
| 158 | ||
| 144 | 159 | function refundRest(reason: string): string { |
| 145 | const order = recent(100)[cursor]; | |
| 160 | const order = orderRows()[cursor]; | |
| 146 | 161 | if (!order) return "No order selected."; |
| 147 | 162 | const left = refundableCents(order); |
| 148 | 163 | if (left <= 0) return `${order.number} has nothing left to refund.`; |
| @@ -187,6 +202,8 @@ process.stdin.on("data", (key: string) => { | ||
| 187 | 202 | if (key === "s" && tab === "stock") |
| 188 | 203 | if (isAdmin) prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock }; |
| 189 | 204 | else message = "Moving stock is for an admin."; |
| 205 | if (key === "/" && tab === "orders") | |
| 206 | prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders }; | |
| 190 | 207 | if (key === "r" && tab === "orders") |
| 191 | 208 | if (isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest }; |
| 192 | 209 | else message = "Refunding is for an admin."; |
src/units.test.ts +43 −6
| @@ -11,6 +11,8 @@ const { encode, decode, put, add, quote } = await import("./cart.ts"); | ||
| 11 | 11 | const { sign, unsign, loadSecret } = await import("./sign.ts"); |
| 12 | 12 | const { apiBase, verifyWebhook, chosen } = await import("./gateway.ts"); |
| 13 | 13 | const limit = await import("./ratelimit.ts"); |
| 14 | const mine = await import("./mine.ts"); | |
| 15 | const peer = await import("./peer.ts"); | |
| 14 | 16 | const { escape } = await import("./html.ts"); |
| 15 | 17 | const catalog = await import("./catalog.ts"); |
| 16 | 18 | const discounts = await import("./discounts.ts"); |
| @@ -245,19 +247,54 @@ test("a bucket empties and refills", () => { | ||
| 245 | 247 | limit.forget(); |
| 246 | 248 | const rule = { name: "test", capacity: 2, perSeconds: 60 }; |
| 247 | 249 | const now = Date.now(); |
| 248 | expect(limit.take(rule, "someone", now)).toBe(true); | |
| 249 | expect(limit.take(rule, "someone", now)).toBe(true); | |
| 250 | expect(limit.take(rule, "someone", now)).toBe(false); | |
| 251 | expect(limit.retryAfter(rule, "someone", now)).toBeGreaterThan(0); | |
| 252 | expect(limit.take(rule, "someone-else", now)).toBe(true); | |
| 253 | expect(limit.take(rule, "someone", now + 60_000)).toBe(true); | |
| 250 | const someone = { address: "198.51.100.1" }; | |
| 251 | const else_ = { address: "198.51.100.2" }; | |
| 252 | expect(limit.take(rule, someone, now)).toBe(true); | |
| 253 | expect(limit.take(rule, someone, now)).toBe(true); | |
| 254 | expect(limit.take(rule, someone, now)).toBe(false); | |
| 255 | expect(limit.retryAfter(rule, someone, now)).toBeGreaterThan(0); | |
| 256 | expect(limit.take(rule, else_, now)).toBe(true); | |
| 257 | expect(limit.take(rule, someone, now + 60_000)).toBe(true); | |
| 254 | 258 | limit.forget(); |
| 255 | 259 | }); |
| 256 | 260 | |
| 261 | test("a peer is the socket's address, checked for the shape of one", () => { | |
| 262 | expect(peer.peerOf("127.0.0.1")).toEqual({ address: "127.0.0.1" }); | |
| 263 | expect(peer.peerOf(undefined)).toEqual({ address: "local" }); | |
| 264 | // Not an address is not a bucket key and not a line in an audit trail. | |
| 265 | expect(peer.peerOf("203.0.113.9 refunded by an admin")).toEqual({ address: "local" }); | |
| 266 | ||
| 267 | expect(peer.address("192.168.0.1")).toBe("192.168.0.1"); | |
| 268 | expect(peer.address(" ::ffff:127.0.0.1 ")).toBe("::ffff:127.0.0.1"); | |
| 269 | expect(peer.address("999.1.1.1")).toBeNull(); | |
| 270 | expect(peer.address("evil.example.com")).toBeNull(); | |
| 271 | expect(peer.address("1.2.3.4 refunded by an admin")).toBeNull(); | |
| 272 | expect(peer.address("1".repeat(peer.MAX + 1))).toBeNull(); | |
| 273 | expect(peer.address(null)).toBeNull(); | |
| 274 | }); | |
| 275 | ||
| 257 | 276 | test("output is escaped", () => { |
| 258 | 277 | expect(escape(`<script>"&'`)).toBe("<script>"&'"); |
| 259 | 278 | }); |
| 260 | 279 | |
| 280 | test("the orders a browser remembers are signed, deduplicated and capped", () => { | |
| 281 | const secret = "a-secret-long-enough-for-hmac"; | |
| 282 | const remembered = ["BZ-AAAABBBB", "BZ-CCCCDDDD"].reduce(mine.remember, [] as string[]); | |
| 283 | expect(remembered).toEqual(["BZ-CCCCDDDD", "BZ-AAAABBBB"]); | |
| 284 | expect(mine.remember(remembered, "BZ-AAAABBBB")).toEqual(["BZ-AAAABBBB", "BZ-CCCCDDDD"]); | |
| 285 | expect(mine.remember(remembered, "not-an-order")).toEqual(remembered); | |
| 286 | ||
| 287 | let many: string[] = []; | |
| 288 | for (let i = 0; i < mine.KEEP + 5; i++) many = mine.remember(many, `BZ-ORDER${String(i).padStart(3, "0")}`); | |
| 289 | expect(many.length).toBe(mine.KEEP); | |
| 290 | ||
| 291 | const cookie = mine.encode(remembered, secret); | |
| 292 | expect(mine.decode(cookie, secret)).toEqual(remembered); | |
| 293 | expect(mine.decode(cookie, "another-secret-entirely-here")).toEqual([]); | |
| 294 | expect(mine.decode(cookie.replace(/^./, "Z"), secret)).toEqual([]); | |
| 295 | expect(mine.decode(undefined, secret)).toEqual([]); | |
| 296 | }); | |
| 297 | ||
| 261 | 298 | test("the settings the admin password left behind are deleted, and nothing else is", () => { |
| 262 | 299 | setSetting("admin_demo_password", "whatever-it-was"); |
| 263 | 300 | setSetting("admin_salt", "and-its-salt"); |
ping