/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The HTTP layer: server-rendered pages over Crafter.Network's ListenerHTTP1. // // Deployment shape — Caddy terminates TLS and reverse-proxies plaintext to // 127.0.0.1, so this listener speaks HTTP/1.1 without TLS of its own. That is // also why it is ListenerHTTP1 rather than ListenerHTTP: Caddy cannot // reverse_proxy to an HTTP/3 upstream, which ruled out the QUIC listener. // // What this serves and what it does not: pages only. Static assets // (catcrafts.wasm, styles.css, the JS bridges, media) stay with Caddy's // file_server — it does sendfile, precompressed variants and caching far // better than anything worth writing here. Every route below is HTML or XML // generated from Catcrafts.Shared. // // The point of all of it is that a crawler, a reader with JavaScript off, and // the wasm app all get markup from the SAME renderers, so a page cannot mean // one thing to a search engine and another to a visitor. module; module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; using namespace Crafter; namespace Catcrafts::Server { // Request provenance. Both are pure and declared in the module interface, // where the reasoning for each lives; the self-test drives them directly. std::string_view ClientAddressFromForwarded(std::string_view forwarded) { // The RIGHTMOST entry, because that is the one Caddy appended. Anything to // its left is whatever the client felt like claiming. const std::size_t comma = forwarded.rfind(','); const std::string_view last = comma == std::string_view::npos ? forwarded : forwarded.substr(comma + 1); return Form::Trim(last); } bool OriginAllowed(std::string_view origin, std::string_view redirectBase) { if (origin.empty()) return true; // not a browser form post; see the header // A trailing slash is legal in a configured base and never present in an // Origin header, so normalise both ends rather than depend on the operator. auto trim = [](std::string_view s) { while (!s.empty() && s.back() == '/') s.remove_suffix(1); return s; }; const std::string_view want = trim(redirectBase); // An unconfigured base must not silently accept every origin. if (want.empty()) return false; return trim(origin) == want; } namespace { // Loaded once at startup. The content files are generated at build time (CI // fetches the fediverse posts before the build), so they cannot change under // a running process, and re-reading them per request would be pure waste. Views::SiteContent gContent; std::string gBootScripts; std::string gCssHref = "/styles.css"; // The payment rails, installed by ConfigurePayments before Serve; two null // rails mean checkout answers 503 rather than creating orders nothing can pay. PaymentRails gRails; std::string gRedirectBase = "https://catcrafts.net"; // The bank-derived aggregates for /financials live in Catcrafts.Server- // Financials.cpp, which owns their file and the bunq callback that updates // them. They are read through CurrentFinancials() per request rather than // cached: unlike the content files they CAN change under a running process, // and live is the page's whole promise. // The reconciler's sweep cadence: the shortest interval any configured rail // asks for. Each order is still paced by ITS OWN rail's interval inside the // loop — a shared sweep that ran at the slower rail's pace would make the // faster one late for every order, and one that ran at the faster pace would // poll the slower provider harder than it asked to be polled. std::chrono::seconds SweepInterval() { std::chrono::seconds out = std::chrono::seconds(10); bool first = true; for (const PaymentRail* rail : { gRails.bank.get(), gRails.crypto.get() }) { if (!rail) continue; const std::chrono::seconds want = rail->PollInterval(); out = first ? want : std::min(out, want); first = false; } return out; } // The callback URL's fixed prefix; everything after it is the shared secret. // Under /api because Caddy proxies that prefix straight through and the // analytics ingest censors it out of the public report (deploy/README.md) — // a URL carrying a secret must not end up on a page anyone can read. inline constexpr std::string_view kBunqCallbackPrefix = "/api/bunq/"; std::string ReadFile(const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); if (!in) return {}; std::ostringstream buf; buf << in.rdbuf(); return buf.str(); } // Declared ahead: the order page (in RenderPage, below) runs one // reconciliation step on arrival; the definition lives with the checkout // handler further down. struct AdvanceResult { std::string status; std::string paidVia; }; std::optional PollAndAdvance(const OrderRecord& order); bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval); std::string NowIso8601(); // Common headers on every HTML response. // // `Cache-Control` is short rather than absent: these pages are cheap to // regenerate, and a minute of shared caching absorbs a burst without making a // content update wait. `X-Content-Type-Options` because a page whose body is // attacker-influenced text should never be sniffed into something executable. void ApplyPageHeaders(HTTPResponse& res, std::string_view contentType, bool cacheable, bool noindex) { res.headers["content-type"] = std::string(contentType); res.headers["x-content-type-options"] = "nosniff"; res.headers["referrer-policy"] = "strict-origin-when-cross-origin"; res.headers["cache-control"] = cacheable ? "public, max-age=60, stale-while-revalidate=600" : "no-store"; if (noindex) res.headers["x-robots-tag"] = "noindex, nofollow"; } // Render one route to a full HTTP response. // // The status comes from the renderer, not from this function: RenderRoute // already returns 404 for an unknown path and 301 for a legacy /blog URL. That // is what turns the app's soft-404 into a real one — the wasm app could only // ever render a 404 page under an HTTP 200, which tells a crawler the URL is // valid. HTTPResponse RenderPage(std::string_view target) { const std::string_view path = PathWithoutQueryHTTP(target); // Everything after '?'. ListenerHTTP1 dispatches on the path alone, so the // query has to be recovered from the raw target here. std::string_view query; if (const std::size_t q = target.find('?'); q != std::string_view::npos) { query = target.substr(q); } const Route route = ParseRoute(path, query); // The product page embeds the live carrier rate table into its checkout // preview, and that table is runtime state — so, like orders below, it is // rendered here rather than through the shared dispatch (which the wasm // backend-down fallback uses with no rate table at all, and therefore // quotes no totals: with the zone fallback gone there is nothing for it to // price from, which is correct — that path cannot reach checkout either). if (route.kind == RouteKind::Product) { if (const Product* product = gContent.FindProduct(route.slug)) { const ShippingTable ship = CurrentShippingTable(); const Views::RenderedPage page = Views::RenderProduct(*product, gContent.rates, ship.perCountry, {}, {}, CryptoPaymentAvailable()); HTTPResponse res; res.status = std::to_string(page.status); ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/true, page.meta.noindex); res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product), Views::RenderFooter(), {}, gCssHref); return res; } // fall through: unknown slug renders the shared 404 below } // The invoice download. Paid orders only; anything else is the same 404 // an unknown token gets. The signature requirement is strict: with a key // configured, a signing failure is a 500, never an unsigned invoice. if (route.kind == RouteKind::Invoice) { HTTPResponse res; std::optional order = FindOrder(route.slug); // A donation has no invoice — nothing was supplied — so its token // answers the same 404 an unknown one does rather than minting a // number for a document that must not exist. if (!order || order->donation || (order->status != "paid" && order->status != "shipped")) { res.status = "404"; ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true); res.body = "Not found\n"; return res; } // Orders paid before invoicing existed get their number on first // download — still sequential, just late. if (order->invoiceNumber.empty()) { if (AssignInvoiceNumber(order->token, NowIso8601())) { order = FindOrder(route.slug); } } if (!order || order->invoiceNumber.empty()) { res.status = "500"; ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true); res.body = "Could not allocate an invoice number\n"; return res; } std::string productName = order->product; std::string colorLabel = order->color; if (const Product* pr = gContent.FindProduct(order->product)) { productName = pr->name; if (const Variant* v = pr->FindVariant(order->color)) colorLabel = v->label; } std::string body = BuildInvoiceMarkdown(*order, productName, colorLabel); if (InvoiceSigningConfigured()) { const auto signedText = ClearsignInvoice(body); if (!signedText) { res.status = "500"; ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true); res.body = "Invoice signing failed; try again shortly\n"; return res; } body = *signedText; } else { body = "UNSIGNED — development copy; production invoices are " "GPG-clearsigned.\n\n" + body; } res.status = "200"; res.headers["content-type"] = "text/markdown; charset=utf-8"; res.headers["content-disposition"] = "attachment; filename=\"catcrafts-invoice-" + order->invoiceNumber + ".md\""; res.headers["cache-control"] = "no-store"; res.headers["x-robots-tag"] = "noindex, nofollow"; res.headers["x-content-type-options"] = "nosniff"; res.body = std::move(body); return res; } // Orders are the one route whose content lives in server state rather than // the build-time content files, so it is rendered here instead of through // the shared dispatch (whose Order case is the backend-down fallback). if (route.kind == RouteKind::Order) { HTTPResponse res; std::optional order = FindOrder(route.slug); if (!order) { // Unknown and malformed tokens are the same 404 — the URL shape // must not reveal whether a token was "close". res.status = "404"; ApplyPageHeaders(res, "text/html; charset=utf-8", false, true); const Views::RenderedPage nf = Views::RenderNotFound(route.path); res.body = Views::RenderDocument(nf, Views::RenderNav(RouteKind::Shop), Views::RenderFooter(), {}, gCssHref); return res; } // The buyer usually arrives here seconds after paying, redirected by // the provider — but the reconciler may not have polled yet. Ask the // rail right now so the page they land on already says paid, instead // of an alarming "awaiting payment" that flips ten seconds later. // Still the poll-is-truth rule: this trusts the provider's // authenticated answer, never the fact of being redirected. // // Gated to one call per token per rail interval — see // ArrivalPollAllowed. A reload past that renders from the ledger and // lets the reconciler do its job, which is the whole point of having // one. The interval is the ORDER'S rail's, so a crypto order is not // paced by Mollie's cadence or the other way round. if (order->status == "awaiting_payment") { if (const PaymentRail* rail = gRails.For(order->payChoice); rail && ArrivalPollAllowed(order->token, rail->PollInterval())) { if (const auto advanced = PollAndAdvance(*order)) { order->status = advanced->status; order->paidVia = advanced->paidVia; } } } OrderView view; view.token = order->token; view.reference = order->reference; view.status = order->status; view.payChoice = order->payChoice; view.payUrl = order->payUrl; view.createdAt = order->createdAt; view.country = order->buyer.country; view.goodsMinor = order->goodsMinor; view.shippingMinor = order->shippingMinor; view.totalMinor = order->totalMinor; view.vatIncluded = order->vatIncluded; view.donation = order->donation; view.quantity = order->quantity; view.unitMinor = order->unitMinor; if (const Product* p = gContent.FindProduct(order->product)) { view.productName = p->name; if (const Variant* v = p->FindVariant(order->color)) { view.colorLabel = v->label; } else { view.colorLabel = order->color; } } else { view.productName = order->product; view.colorLabel = order->color; } // Self-hosted rails have no provider page to resume at — the order // page IS the payment page, so ask the rail what to render. Hosted // rails return nullopt and keep their button. Only while awaiting: a // paid page repeating "send money here" would read as a second ask. if (order->status == "awaiting_payment") { if (const PaymentRail* rail = gRails.For(order->payChoice)) { if (auto instr = rail->Instructions(order->payId, order->totalMinor)) { OrderCryptoPay pay; pay.address = instr->address; pay.amount = instr->amount; const std::int64_t now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); pay.minutesLeft = (instr->deadlineUnix - now) / 60; for (auto& c : instr->chains) { pay.chains.push_back({ std::move(c.name), std::move(c.contract), std::move(c.link), std::move(c.note) }); } view.cryptoPay = std::move(pay); } } } // The indicative national-currency line: ECB reference rates baked in // at build time, converted to whole units, labelled with the rate // date. Purely informative — the euro amount is the charge. std::string indicative; if (auto cur = Money::CurrencyFor(order->buyer.country)) { if (const std::int64_t rate = gContent.rates.Find(cur->code); rate > 0) { indicative = std::format( "{} · ECB reference rate {}", Money::FormatIndicative(*cur, Money::ConvertIndicative(order->totalMinor, rate)), gContent.rates.date); } } const Views::RenderedPage page = Views::RenderOrderStatus(view, indicative); res.status = std::to_string(page.status); // Personal content behind a capability URL: never cached anywhere. ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false, /*noindex=*/true); res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Shop), Views::RenderFooter(), {}, gCssHref); return res; } // The financials page: lifetime sales folded live from the order ledger, // donations and expenses from the bank-aggregates file. Server-rendered // here because both inputs are runtime state; the shared dispatch's case // is the backend-down fallback, like orders. Refolding the ledger per // request is what every order lookup already does, and the page's whole // promise is that a refresh shows the current totals — so no caching. if (route.kind == RouteKind::Financials) { const std::vector orders = ListOrders(); const SalesSummary sales = SummarizeSales(orders); const Financials fin = CurrentFinancials(); // Shop donations ride along from the same fold: they are live like // sales, and the renderer joins them with the bank-side donations. const Views::RenderedPage page = Views::RenderFinancials(sales.count, sales.totalMinor, fin, sales.donationCount, sales.donationsMinor); HTTPResponse res; res.status = std::to_string(page.status); ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false, page.meta.noindex); res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Financials), Views::RenderFooter(), {}, gCssHref); return res; } const Views::RenderedPage page = Views::RenderRoute(route, gContent); HTTPResponse res; res.status = std::to_string(page.status); // A retired URL is a real redirect, not a rendered page: send 301 with // Location so the crawler updates its index and the visitor's address bar // shows the canonical path. The body is a courtesy for clients that show it. if (!route.canonicalRedirect.empty()) { res.headers["location"] = route.canonicalRedirect; ApplyPageHeaders(res, "text/html; charset=utf-8", false, true); res.body = "Moved

Moved to " + route.canonicalRedirect + "."; return res; } ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/page.status == 200, page.meta.noindex); // Boot scripts only where the module is actually needed, and that is a // property of the demo rather than of the route: a demo entry declares // needsWasm, so adding one that does not need the renderer costs no change // here. Every other page is complete without it, and shipping ~239 KB of // module to them would buy nothing. bool wantsWasm = false; if (route.kind == RouteKind::Demo) { if (const Demo* d = gContent.FindDemo(route.slug)) wantsWasm = d->needsWasm; } res.body = Views::RenderDocument(page, Views::RenderNav(NavKindFor(route.kind)), Views::RenderFooter(), wantsWasm ? gBootScripts : std::string_view{}, gCssHref); return res; } HTTPResponse ServeSitemap() { HTTPResponse res; std::string out = "\n" "\n"; for (std::string_view p : SitemapPaths()) { out += " https://catcrafts.net"; out += Html::Escape(p).Str(); out += "\n"; } // From the catalogue, not a second hardcoded list. for (const Product& pr : gContent.products) { out += " https://catcrafts.net/shop/"; out += Html::Escape(pr.slug).Str(); out += "\n"; } // Post pages, from the same HasPage() test the "read more" links use — a // sitemap that advertised a post without a body would be pointing crawlers // at the 404 the dispatcher correctly returns for it. for (const Post& po : gContent.posts) { if (!po.HasPage()) continue; out += " https://catcrafts.net/posts/"; out += Html::Escape(po.slug).Str(); out += "\n"; } out += "\n"; ApplyPageHeaders(res, "application/xml; charset=utf-8", true, false); res.body = std::move(out); return res; } HTTPResponse ServeFeed() { HTTPResponse res; ApplyPageHeaders(res, "application/atom+xml; charset=utf-8", true, false); res.body = Views::RenderAtomFeed(gContent.posts); return res; } // Rate limiting on checkout submissions: per-peer first, global as a backstop. // // This used to be a single global cap, on the reasoning that a reverse-proxied // process cannot know its real peer and that trusting a client-settable header // is worse than not limiting at all. The first half was wrong and the second // half made the conclusion dangerous. A SHARED budget is exhaustible by // whoever is rudest: thirty submissions from one script closed checkout for // every real buyer for ten minutes, and one submission every twenty seconds // kept the shop shut indefinitely — a working denial of sales for the price of // a shell loop. A limit that turns one attacker into an outage is not a limit. // // The real peer IS knowable here, carefully: Caddy appends it to // X-Forwarded-For, so the rightmost entry is Caddy's own word rather than the // client's (see ClientAddressFromForwarded, which is where that reasoning // lives). It is trustworthy only because nothing else can reach this listener. // // So the per-peer cap is the actual control, and the global cap stays purely // as a runaway backstop — set high enough that it is not a lever one peer can // pull, since tripping it still denies everyone. A flood broad enough to reach // it is an infrastructure problem, and belongs to Caddy and the host. // // Unproxied requests (dev, e2e, a direct curl at the loopback port) carry no // X-Forwarded-For. They are charged to the global budget only — there is no // peer to key on, and inventing one would be a lie. using RatePoint = std::chrono::steady_clock::time_point; std::mutex gRateMutex; std::deque gRecentSubmissions; std::unordered_map> gRecentPerPeer; // Per peer: enough for a buyer who mistypes, retries, changes their mind about // a colour and orders twice. Not enough to be a source of volume. constexpr std::size_t kMaxSubmissionsPerPeer = 6; // Global: a backstop, an order of magnitude above any real ten minutes here. constexpr std::size_t kMaxSubmissionsPerWindow = 240; constexpr auto kRateWindow = std::chrono::minutes(10); bool RateLimitAllows(std::string_view peer) { const auto now = std::chrono::steady_clock::now(); std::lock_guard lock(gRateMutex); auto expire = [&](std::deque& seen) { while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front(); }; expire(gRecentSubmissions); if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false; if (!peer.empty()) { // Expire every peer, not just this one, and drop those whose window has // emptied: otherwise the map keeps one entry per address that ever // submitted, which is a slow leak an attacker chooses the rate of. std::erase_if(gRecentPerPeer, [&](auto& entry) { expire(entry.second); return entry.second.empty(); }); std::deque& seen = gRecentPerPeer[std::string(peer)]; if (seen.size() >= kMaxSubmissionsPerPeer) return false; seen.push_back(now); } gRecentSubmissions.push_back(now); return true; } // RFC 3339 UTC. Recorded so the order log can be read chronologically // without depending on file order. std::string NowIso8601() { return std::format("{:%FT%TZ}", std::chrono::floor( std::chrono::system_clock::now())); } // RFC 5322 date for the email header, always UTC. Without the L flag // std::format's %a/%b are locale-independent English — exactly what a mail // header needs, whatever locale the host booted with. std::string NowRfc2822() { return std::format("{:%a, %d %b %Y %H:%M:%S} +0000", std::chrono::floor( std::chrono::system_clock::now())); } // POST /shop/ — create an order. // // The sequence is: validate -> compute the amount SERVER-SIDE -> get a payment // link from the rail -> persist the order -> 303 to /order/. The // payment link is fetched before the order is written so a rail failure never // strands an unpayable order; the buyer just gets an honest error and their // form back. // // Answers 303 on success rather than rendering the order page inline. That is // the POST/redirect/GET pattern, and it matters for a real form: a rendered // POST response means reloading re-submits, and the back button re-posts. The // redirect leaves the browser on a GET it can safely repeat. HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) { HTTPResponse res; const Product* product = gContent.FindProduct(route.slug); if (!product) { res.status = "404"; ApplyPageHeaders(res, "text/html; charset=utf-8", false, true); const Views::RenderedPage page = Views::RenderNotFound(route.path); res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Shop), Views::RenderFooter(), {}, gCssHref); return res; } // Re-render the product page with errors and the submitted values kept, so a // validation failure never costs the visitor what they typed. const ShippingTable shipTable = CurrentShippingTable(); auto reject = [&](std::vector errors, const Form::Checkout& prev, std::string_view status) { res.status = std::string(status); ApplyPageHeaders(res, "text/html; charset=utf-8", false, true); const Views::RenderedPage page = Views::RenderProduct( *product, gContent.rates, shipTable.perCountry, errors, prev, CryptoPaymentAvailable()); res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product), Views::RenderFooter(), {}, gCssHref); return res; }; // Cross-site request forgery. This POST creates an order and calls the // payment provider, so it must have come from our own form — a page on // another origin must not be able to drive it with a visitor's browser. // OriginAllowed carries the reasoning, including why a MISSING Origin is // accepted (a non-browser client cannot forge cross-site). if (const auto origin = req.headers.find("origin"); origin != req.headers.end()) { if (!OriginAllowed(origin->second, gRedirectBase)) { return reject({{ "", "That submission didn't come from this site. " "Nothing was charged." }}, {}, "403"); } } // Only urlencoded — the form sends nothing else, and accepting more content // types means parsing more attacker-chosen formats for no benefit. const auto ct = req.headers.find("content-type"); if (ct != req.headers.end() && ct->second.find("application/x-www-form-urlencoded") == std::string::npos) { return reject({{ "", "Unsupported form encoding." }}, {}, "415"); } auto fields = Form::ParseUrlEncoded(req.body); if (!fields) { // Oversized or malformed body. 413 rather than 400 when it is a size // problem, since that is actionable. return reject({{ "", "That submission was too large or malformed." }}, {}, req.body.size() > Form::kMaxBodyBytes ? "413" : "400"); } // A donation validates by its own rules: an amount instead of a price, // no address because nothing ships. Everything after validation — rails, // rate limit, storage, redirect — is shared. Form::CheckoutResult parsed = product->donation ? Form::ValidateDonation(*fields) : Form::ValidateCheckout(*fields); if (!parsed.Ok()) { return reject(parsed.errors, parsed.value, "422"); } if (!product->Buyable()) { return reject({{ "", product->ComingSoon() ? "The shop has not opened yet. Nothing was charged." : "This product is temporarily unavailable." }}, parsed.value, "409"); } if (!gRails.Any()) { return reject({{ "", "Checkout is offline right now — nothing was charged. " "Please try again later." }}, parsed.value, "503"); } // The rail the buyer picked. ValidateCheckout has already refused anything // that is not one of the two words, so what remains is the case where the // word is valid but its slot is not configured — a form cached from before // the rail was switched off, or a hand-made post. Say which one is missing // rather than "checkout is offline": the other method is right there and // still works. const bool wantsCrypto = parsed.value.payChoice == Form::kPayCrypto; PaymentRail* rail = gRails.For(parsed.value.payChoice); if (!rail) { return reject({{ "pay", wantsCrypto ? "Crypto payment isn't available right now — nothing " "was charged. Please pick bank or card." : "Bank and card payment isn't available right now — " "nothing was charged. Please pick crypto." }}, parsed.value, "503"); } // Charged to this peer's own budget, so a flood costs the flooder their // checkout and nobody else theirs. std::string_view peer; if (const auto fwd = req.headers.find("x-forwarded-for"); fwd != req.headers.end()) { peer = ClientAddressFromForwarded(fwd->second); } if (!RateLimitAllows(peer)) { return reject({{ "", "Too many submissions just now — please try again shortly." }}, parsed.value, "429"); } std::int64_t unitMinor = 0; Money::Totals totals; if (product->donation) { // THE amount, donation case: the validated buyer-named amount — the // one figure that legitimately arrives from the client, and // ValidateDonation has already bounded it. Nothing ships and no VAT // is charged: a gift with nothing supplied in return is not a // taxable supply, so the whole shipping-and-VAT computation below // simply does not apply. unitMinor = parsed.value.amountMinor; totals.goods = unitMinor; totals.total = unitMinor; totals.shipping = 0; totals.vatCharged = 0; totals.vatIncluded = false; } else { // The variant: submitted slug against the catalogue, defaulting to the // cheapest (which is what the page advertises). A slug we never listed // is a 422, not a guess — a tampered value must not buy an unpriced // colour. const Variant* variant = nullptr; if (!product->variants.empty()) { variant = parsed.value.color.empty() ? product->CheapestVariant() : product->FindVariant(parsed.value.color); if (!variant) { return reject({{ "color", "That is not one of the colours." }}, parsed.value, "422"); } parsed.value.color = variant->slug; } unitMinor = variant ? variant->priceInclMinor : product->priceInclMinor; // THE amount. Computed here from the catalogue, the validated country // and the live shipping table; nothing about money ever arrives from // the client. Shipping is per order, not per unit — one parcel — so // the weight that picks the carrier bracket is the whole order's. if (product->shipWeightGrams <= 0) { // A catalogue bug, not a buyer problem: without a weight no // bracket can be selected. Refuse rather than fall through to the // cheapest rate, and say so in the log where it can be fixed. std::println(std::cerr, "checkout: product '{}' has no shipping weight", product->slug); return reject({{ "", "Shipping for this product can't be priced right now — " "nothing was charged." }}, parsed.value, "503"); } const std::int64_t parcelGrams = product->shipWeightGrams * parsed.value.quantity; const std::optional shippingMinor = ShipCostFor(parsed.value.country, parcelGrams); if (!shippingMinor) { // No rate covers this parcel, so there is no price to charge. // Which of the two refusals it is decides what the buyer can do // about it: an uncovered country is ours to fix, a too-heavy // parcel has a quantity that would work. The error hangs off the // field the buyer would change in each case. const std::int64_t fits = shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams); if (fits <= 0 && parsed.value.quantity == 1) { return reject({{ "country", Form::NoShippingMessage(parsed.value.country) }}, parsed.value, "422"); } return reject({{ "quantity", Form::TooHeavyMessage(parsed.value.country, fits) }}, parsed.value, "422"); } totals = Money::ComputeTotals( unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country); } OrderRecord order; order.token = NewOrderToken(); order.reference = ReferenceFromToken(order.token); order.product = product->slug; order.color = parsed.value.color; order.quantity = parsed.value.quantity; order.unitMinor = unitMinor; order.createdAt = NowIso8601(); order.buyer = parsed.value; order.goodsMinor = totals.goods; order.shippingMinor = totals.shipping; order.totalMinor = totals.total; order.vatIncluded = totals.vatIncluded; order.donation = product->donation; // Normalised, not echoed: the record must name the rail that issued the // link, and an empty submitted choice took the bank rail above. order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank); auto link = rail->CreateLink( order.totalMinor, std::format("{} catcrafts.net", order.reference), std::format("{}/order/{}", gRedirectBase, order.token)); if (!link) { return reject({{ "", "The payment provider can't be reached right now — " "nothing was charged and no order was created. " "Please try again in a few minutes." }}, parsed.value, "502"); } order.payUrl = link->payUrl; order.payId = link->payId; if (!CreateOrder(order)) { // Storage failed (no path configured, disk full, permissions). Tell the // truth: a payment link over an order that was never written is the // worst possible outcome here. return reject({{ "", "Couldn't record the order — something is wrong on this " "end. Nothing was charged. Please try again later." }}, parsed.value, "500"); } std::println(std::cerr, "order {} created: {} {} -> {}", order.reference, Money::FormatMinor(order.totalMinor), order.buyer.country, rail->Name()); // Straight to the payment page — the buyer clicked "buy", not "read an // interim status page". The order page stays the receipt/status URL that // Mollie redirects back to afterwards. res.status = "303"; res.headers["location"] = order.payUrl; res.headers["cache-control"] = "no-store"; res.headers["content-type"] = "text/html; charset=utf-8"; res.body = "Order created

Order created. " "Continue to payment."; return res; } // The gate on the order page's arrival poll. // // Rendering /order/ asks the provider whether the payment landed, so a // buyer redirected back from Mollie sees "paid" immediately instead of an // alarming "awaiting payment" that flips ten seconds later. That is a good // thing to do once. The problem was that it happened on EVERY render: an // outbound HTTPS round trip, on the request thread, holding the rail's mutex, // reachable as often as anyone cared to reload. // // The hole that closes: an attacker places one order — their own, so no token // guessing is involved — and then reloads it in a loop. Every reload spent a // live Mollie API call against the shop's key, and because CreateLink shares // that same mutex, real buyers' checkouts queued behind the flood. The // listener is thread-per-connection with no cap, so the blocked threads piled // up as well. // // One poll per token per rail interval is all the arrival check ever needed. // Its job is to beat the reconciler to the FIRST render, not to become a // second reconciler — everything after that is the reconciler's work, and it // already paces itself by order age. Tying the gate to the rail's own cadence // keeps the two honest about each other: the fake rail's one-second interval // leaves dev and the e2e suite behaving exactly as before. std::mutex gArrivalPollMutex; std::unordered_map gLastArrivalPoll; bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) { const auto now = std::chrono::steady_clock::now(); std::lock_guard lock(gArrivalPollMutex); // Orders settle or lapse; their entries should not outlive them. An hour // idle is far past both, and pruning here keeps the map bounded by live // traffic rather than by every token ever viewed. std::erase_if(gLastArrivalPoll, [&](const auto& entry) { return now - entry.second > std::chrono::hours(1); }); const auto [it, inserted] = gLastArrivalPoll.try_emplace(std::string(token), now); if (inserted) return true; if (now - it->second < interval) return false; it->second = now; return true; } // One reconciliation step for one order: ask the rail, append the transition // if there is one, and report the order's (possibly new) status fields. // Shared by the reconciler thread and the order page's on-arrival check. std::optional PollAndAdvance(const OrderRecord& order) { if (order.status != "awaiting_payment") return std::nullopt; // The rail that ISSUED this order's link, never simply "the rail": asking // the wrong provider about an id it never handed out is at best a 404 and // at worst a question about somebody else's order. A slot that is no // longer configured means this order cannot be polled at all — leave it // awaiting for the manual CLI rather than guess with the other one. PaymentRail* rail = gRails.For(order.payChoice); if (!rail) return std::nullopt; const std::optional paid = rail->CheckPaid(order.payId, order.totalMinor); if (!paid.has_value()) return std::nullopt; if (paid->state == PayState::Paid) { if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) { // The invoice number exists from the moment the money does — // sequential by payment order, which is what the bookkeeping // wants. Never for a donation: no supply, no invoice, and a // number burned on one would leave a gap-shaped question in a // customer's series. if (!order.donation) AssignInvoiceNumber(order.token, NowIso8601()); std::println(std::cerr, "order {} paid ({}, via {})", order.reference, Money::FormatMinor(order.totalMinor), paid->method.empty() ? "?" : paid->method); return AdvanceResult{ "paid", paid->method }; } } else if (paid->state == PayState::Dead) { if (AppendOrderStatus(order.token, "cancelled", NowIso8601())) { std::println(std::cerr, "order {} lapsed (payment {})", order.reference, order.payId); return AdvanceResult{ "cancelled", {} }; } } return std::nullopt; } // Liveness for Caddy's health_uri and for the deploy script. Deliberately does // not touch the content or render anything, so it stays true even if a content // file is malformed. HTTPResponse ServeHealth() { HTTPResponse res; res.headers["content-type"] = "text/plain; charset=utf-8"; res.headers["cache-control"] = "no-store"; res.body = std::format("ok\nprojects={}\nposts={}\n", gContent.projects.size(), gContent.posts.size()); return res; } // Crafter.Build emits the boot scripts with RELATIVE srcs — src="runtime.js?v=…" // — which the browser resolves against the current directory. That is correct at // "/" and wrong at every deeper path: on /demos/raytracer it asks for // /demos/runtime.js, which does not exist, so Caddy's try_files hands back // index.html and the browser blocks the module for having a text/html MIME type. // The symptom is four NS_ERROR_CORRUPTED_CONTENT failures and a dead page. // // Rooting the src makes one tag correct at any depth, which matters because // every product and legal page is two segments deep. std::string RootRelativeSrc(std::string tag) { const std::size_t at = tag.find("src=\""); if (at == std::string::npos) return tag; const std::size_t v = at + 5; if (v >= tag.size()) return tag; const std::string_view rest = std::string_view(tag).substr(v); // A leading '/' covers both "/runtime.js" and protocol-relative "//host/x"; // both are already absolute and must be left alone. if (rest.starts_with("/") || rest.starts_with("http://") || rest.starts_with("https://")) return tag; tag.insert(v, "/"); return tag; } } // namespace void LoadContent(const std::filesystem::path& contentDir, const std::filesystem::path& bundleIndexHtml) { // Authored content is compiled in; only pipeline-generated data (posts, // rates) is read from disk. gContent.projects = Content::Projects(); gContent.products = Content::Products(); gContent.legal = Content::LegalPages(); gContent.demos = Content::Demos(); gContent.posts = LoadPosts(ReadFile(contentDir / "posts.json")); gContent.rates = LoadRates(ReadFile(contentDir / "rates.json")); // The ", pos); if (end == std::string::npos) break; gBootScripts += RootRelativeSrc(index.substr(pos, end + 9 - pos)); gBootScripts += '\n'; pos = end + 9; } } } std::size_t ContentPostCount() { return gContent.posts.size(); } std::size_t ContentProjectCount() { return gContent.projects.size(); } std::size_t ContentProductCount() { return gContent.products.size(); } void ConfigurePayments(PaymentRails rails, std::string redirectBase) { gRails = std::move(rails); if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase); } bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; } namespace { // The reconciler: the ONLY thing that moves an order to paid. // // The design rule from the plan holds even without webhooks: payment state // comes from an authenticated poll against the provider, never from anything // the client (or a redirect parameter) says. This thread sweeps awaiting // orders and asks the rail; a positive answer appends a status event. // // Poll pacing backs off with order age — a buyer mid-flow gets answers at // their provider's own cadence, a two-hour-old order drops to every 10 // minutes, and after seven days it stops being polled (a very late payment is // then found by the manual CLI path, which exists for exactly that). // // Two timestamps per order rather than one. The sweep runs at the FASTEST // configured rail's cadence, because that rail's orders deserve it, so // "poll on every pass" would silently poll the slower provider at the faster // one's rate — with two rails that is no longer a rounding error but double // the request volume the slower one was promised. `first` drives the age backoff, // `last` enforces the interval; keeping them apart also retires the modulo // pacing that used to approximate this with one. void ReconcilerLoop(const std::stop_token& stop) { struct Seen { std::chrono::steady_clock::time_point first; // for the age backoff std::chrono::steady_clock::time_point last; // for the interval }; std::unordered_map seen; while (!stop.stop_requested()) { std::this_thread::sleep_for(SweepInterval()); if (stop.stop_requested()) break; const auto now = std::chrono::steady_clock::now(); for (const OrderRecord& order : ListOrders()) { if (order.status != "awaiting_payment") { seen.erase(order.token); continue; } // The order's OWN provider: the two rails ask to be polled at // different rates, and a sweep running at the faster one's cadence // must not push the slower one. const PaymentRail* rail = gRails.For(order.payChoice); if (!rail) continue; // Age from the record's own timestamp is string math we don't // need: steady-clock first-seen is good enough for backoff. auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now }); if (!inserted) { using namespace std::chrono; const auto age = now - it->second.first; if (age > hours(24 * 7)) continue; const auto due = age > hours(2) ? seconds(minutes(10)) : rail->PollInterval(); if (now - it->second.last < due) continue; it->second.last = now; } // Paid, lapsed (the provider says the payment can never arrive), // or nothing to report — the shared step handles the transition. if (PollAndAdvance(order)) seen.erase(order.token); } } } // Build and hand ONE order's confirmation to the mail command. False means // "not sent" in every failure mode — the caller retries later, and the // notified event that stops a resend is only written on success. bool SendConfirmationEmail(const OrderRecord& order) { // The invoice rides along, so its number must exist. It normally does // from the paid transition; an order paid before invoicing existed gets // its number here, exactly as the download route grants one. Donations // skip all of it: no supply, no invoice — their confirmation is a // thank-you with nothing attached. OrderRecord o = order; if (!o.donation && o.invoiceNumber.empty()) { if (!AssignInvoiceNumber(o.token, NowIso8601())) return false; const auto reread = FindOrder(o.token); if (!reread || reread->invoiceNumber.empty()) return false; o = *reread; } std::string productName = o.product; std::string colorLabel = o.color; if (const Product* pr = gContent.FindProduct(o.product)) { productName = pr->name; if (const Variant* v = pr->FindVariant(o.color)) colorLabel = v->label; } // Same signature rule as the download: with a key configured, a signing // failure means no email now (retry later), never an unsigned invoice. std::string invoice; if (!o.donation) { invoice = BuildInvoiceMarkdown(o, productName, colorLabel); if (InvoiceSigningConfigured()) { const auto signedText = ClearsignInvoice(invoice); if (!signedText) { std::println(std::cerr, "mail: invoice signing failed for {} — retrying later", o.reference); return false; } invoice = *signedText; } else { invoice = "UNSIGNED — development copy; production invoices are " "GPG-clearsigned.\n\n" + invoice; } } const std::string message = BuildOrderConfirmationEmail( o, productName, colorLabel, MailFrom(), std::format("{}/order/{}", gRedirectBase, o.token), invoice, NowRfc2822()); if (message.empty()) { // The address failed the envelope shape check. That cannot heal by // waiting, but the ledger stays honest: no notified event is written // for an email that never left, and the backoff caps the log noise. std::println(std::cerr, "mail: order {} has an unmailable address", o.reference); return false; } if (!SendMailMessage(message)) return false; AppendOrderNotified(o.token, NowIso8601()); std::println(std::cerr, "order {} confirmation emailed", o.reference); return true; } // The mailer: watches the ledger for paid orders that were never emailed. // // A sweep rather than a hook inside PollAndAdvance, deliberately: every path // to paid — the reconciler, the buyer's arrival poll, the manual CLI even on // a later restart — funnels into the same ledger, so the ledger is the one // thing worth watching. It also keeps the SMTP handoff off the request // thread: the arrival poll renders the buyer's order page, and that page // must not wait on a mail server. Crash-safety errs toward a duplicate // email (send, then append the notified event), never a missing one. void MailerLoop(const std::stop_token& stop) { struct Attempt { int failures = 0; std::chrono::steady_clock::time_point next; }; std::unordered_map attempts; while (!stop.stop_requested()) { // Seconds after the paid transition, not milliseconds — nobody // watches their inbox harder than that, and the fold is cheap at // this volume. std::this_thread::sleep_for(std::chrono::seconds(2)); if (stop.stop_requested()) break; for (const OrderRecord& order : ListOrders()) { if (order.status != "paid" && order.status != "shipped") continue; // A donation without an email address asked for no confirmation: // there is nothing to send and nobody to send it to, which is a // settled state, not a retryable failure. Goods orders always // have an address (checkout requires one), so an empty one there // can only be a hand-edited ledger — skipping is still righter // than retrying an unmailable message forever. if (order.buyer.email.empty()) continue; if (!order.confirmationSentAt.empty()) { attempts.erase(order.token); continue; } const auto now = std::chrono::steady_clock::now(); Attempt& att = attempts[order.token]; if (att.failures > 0 && now < att.next) continue; if (SendConfirmationEmail(order)) { attempts.erase(order.token); } else { // 1, 2, 4 … 64 minutes: a broken mail command must not turn // the journal into a metronome, but recovery is still found // within the hour without a restart. ++att.failures; att.next = now + std::chrono::minutes( 1 << std::min(att.failures - 1, 6)); } } } } } // namespace int Serve(std::uint16_t port) { // Exact-match routes for the fixed set, and a fallback for everything else. // // The fallback is what makes this work at all: the app's own ParseRoute is // the single route table shared with the wasm frontend, so rather than // enumerate paths here (and risk the two disagreeing), unmatched requests // are handed straight to it. It also means /shop/ and /order/ // need no listener change when they arrive — they are just more paths // ParseRoute already knows about. std::unordered_map> routes{ { "/sitemap.xml", [](const HTTPRequest&) { return ServeSitemap(); } }, { "/feed.xml", [](const HTTPRequest&) { return ServeFeed(); } }, { "/api/healthz", [](const HTTPRequest&) { return ServeHealth(); } }, }; auto fallback = [](const HTTPRequest& req) -> HTTPResponse { // The bunq mutation callback. Handled here rather than through // ParseRoute because the path carries a SECRET — the shared route // table is compiled into the wasm bundle that ships to every browser, // and a secret has no business being in it. // // Everything unauthorised answers 404, never 401: the endpoint should // not confirm its own existence to a prober, exactly as an unknown // order token does not confirm the shape of a real one. if (const std::string_view path = PathWithoutQueryHTTP(req.path); path.starts_with(kBunqCallbackPrefix)) { HTTPResponse res; res.headers["content-type"] = "text/plain; charset=utf-8"; res.headers["cache-control"] = "no-store"; res.headers["x-robots-tag"] = "noindex, nofollow"; const std::string_view secret = path.substr(kBunqCallbackPrefix.size()); if (!BunqCallbackConfigured() || req.method != "POST" || req.body.size() > Form::kMaxBodyBytes) { res.status = "404"; res.body = "Not found\n"; return res; } std::string_view signature; if (const auto h = req.headers.find("x-bunq-server-signature"); h != req.headers.end()) { signature = h->second; } if (!BunqCallbackAuthorised(secret, req.body, signature)) { res.status = "404"; res.body = "Not found\n"; return res; } // 200 for everything the endpoint understood, including a // withheld or duplicate mutation: those are correct outcomes, and // a non-2xx would make bunq redeliver a callback that was already // handled exactly as intended. Only a failed WRITE earns a 500, // because a retry of that genuinely could succeed. const BunqIngestResult result = IngestBunqNotification(req.body); res.status = result == BunqIngestResult::Failed ? "500" : "200"; res.body = result == BunqIngestResult::Failed ? "Could not record\n" : "OK\n"; return res; } // A POST to a product page is a checkout submission. if (req.method == "POST") { const Route route = ParseRoute(PathWithoutQueryHTTP(req.path)); if (route.kind == RouteKind::Product) return HandleCheckout(req, route); HTTPResponse res; res.status = "405"; res.headers["allow"] = "GET, HEAD"; res.headers["content-type"] = "text/plain; charset=utf-8"; res.body = "Method not allowed\n"; return res; } // Only GET and HEAD reach a page. Anything else against a page URL is a // client error, and answering 405 with Allow is more useful than // rendering a page for a request that will be silently ignored. if (req.method != "GET" && req.method != "HEAD") { HTTPResponse res; res.status = "405"; res.headers["allow"] = "GET, HEAD, POST"; res.headers["content-type"] = "text/plain; charset=utf-8"; res.body = "Method not allowed\n"; return res; } return RenderPage(req.path); }; // The reconciler only exists when there is a rail to ask. jthread: the // stop token fires on destruction, so shutdown does not hang on a sleep. std::optional reconciler; if (gRails.Any()) { reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); }); } // The mailer only exists when a mail command is configured. Without one // the order page and the invoice download remain the buyer's receipt — // degraded, not broken, like every other optional integration here. std::optional mailer; if (MailConfigured()) { mailer.emplace([](std::stop_token st) { MailerLoop(st); }); } // Shipping rates: one fetch at startup, then daily. RefreshShippingTable // is a no-op without Sendcloud credentials, and every failure mode leaves // the previous table (cached or zone fallback) in charge. std::jthread shippingRefresher([](std::stop_token st) { RefreshShippingTable(); while (!st.stop_requested()) { for (int i = 0; i < 24 * 60 && !st.stop_requested(); ++i) { std::this_thread::sleep_for(std::chrono::minutes(1)); } if (!st.stop_requested()) RefreshShippingTable(); } }); ListenerHTTP1 listener(port, std::move(routes), std::move(fallback)); std::println("catcrafts-server: listening on 127.0.0.1:{} " "({} projects, {} posts, payments: bank={} crypto={})", port, gContent.projects.size(), gContent.posts.size(), gRails.bank ? gRails.bank->Name() : "off", gRails.crypto ? gRails.crypto->Name() : "off"); listener.Listen(); return 0; } } // namespace Catcrafts::Server