2026-08-05 04:18:37 +02:00
|
|
|
/*
|
|
|
|
|
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 {
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The payment rails, installed by ConfigurePayments before Serve; two null
|
|
|
|
|
// rails mean checkout answers 503 rather than creating orders nothing can pay.
|
|
|
|
|
PaymentRails gRails;
|
2026-08-05 04:18:37 +02:00
|
|
|
std::string gRedirectBase = "https://catcrafts.net";
|
|
|
|
|
|
2026-08-20 23:33:50 +02:00
|
|
|
// Where the bunq callback writes, and the secret path that authorises it.
|
|
|
|
|
// Both empty means the endpoint does not exist at all — an unconfigured
|
|
|
|
|
// webhook must not answer, or the shop would carry a public write-ish endpoint
|
|
|
|
|
// nobody asked for.
|
|
|
|
|
std::filesystem::path gCreditsPath;
|
|
|
|
|
std::string gWebhookPath;
|
|
|
|
|
|
2026-08-14 02:50:58 +02:00
|
|
|
// The bank-derived aggregates for /financials live in Catcrafts.Server-
|
2026-08-17 11:36:54 +02:00
|
|
|
// Financials.cpp, which owns their file. 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.
|
2026-08-14 02:50:58 +02:00
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
2026-08-13 23:34:19 +02:00
|
|
|
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
2026-08-20 02:50:28 +02:00
|
|
|
bool MoneySeenRecently(std::string_view token);
|
2026-08-05 04:18:37 +02:00
|
|
|
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
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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).
|
2026-08-05 04:18:37 +02:00
|
|
|
if (route.kind == RouteKind::Product) {
|
|
|
|
|
if (const Product* product = gContent.FindProduct(route.slug)) {
|
|
|
|
|
const ShippingTable ship = CurrentShippingTable();
|
|
|
|
|
const Views::RenderedPage page =
|
2026-08-13 23:34:19 +02:00
|
|
|
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
|
2026-08-20 20:15:47 +02:00
|
|
|
{}, {}, CryptoPaymentAvailable(),
|
|
|
|
|
BankPaymentAvailable());
|
2026-08-05 04:18:37 +02:00
|
|
|
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<OrderRecord> order = FindOrder(route.slug);
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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")) {
|
2026-08-05 04:18:37 +02:00
|
|
|
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<OrderRecord> 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
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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
|
2026-08-20 20:15:47 +02:00
|
|
|
// paced by the bank rail's cadence or the other way round.
|
2026-08-05 04:18:37 +02:00
|
|
|
if (order->status == "awaiting_payment") {
|
2026-08-13 23:34:19 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
OrderView view;
|
|
|
|
|
view.token = order->token;
|
|
|
|
|
view.reference = order->reference;
|
|
|
|
|
view.status = order->status;
|
2026-08-13 23:34:19 +02:00
|
|
|
view.payChoice = order->payChoice;
|
2026-08-05 04:18:37 +02:00
|
|
|
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;
|
2026-08-17 11:04:03 +02:00
|
|
|
view.donation = order->donation;
|
2026-08-05 04:18:37 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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;
|
2026-08-20 20:15:47 +02:00
|
|
|
// Set only by bank-transfer rails, and what the renderer
|
|
|
|
|
// switches on. The structured reference is derived from the
|
|
|
|
|
// order token rather than carried by the rail, so the two
|
|
|
|
|
// forms the page prints cannot disagree with each other.
|
|
|
|
|
pay.beneficiary = instr->beneficiary;
|
|
|
|
|
pay.bic = instr->bic;
|
|
|
|
|
if (!instr->beneficiary.empty()) {
|
|
|
|
|
pay.structuredReference = CreditorReferenceFromToken(order->token);
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
const std::int64_t now =
|
|
|
|
|
std::chrono::duration_cast<std::chrono::seconds>(
|
|
|
|
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
|
|
|
|
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
|
2026-08-20 02:50:28 +02:00
|
|
|
pay.seen = MoneySeenRecently(order->token);
|
2026-08-15 00:54:05 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 02:50:58 +02:00
|
|
|
// 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<OrderRecord> orders = ListOrders();
|
|
|
|
|
const SalesSummary sales = SummarizeSales(orders);
|
|
|
|
|
const Financials fin = CurrentFinancials();
|
2026-08-17 11:04:03 +02:00
|
|
|
// Shop donations ride along from the same fold: they are live like
|
|
|
|
|
// sales, and the renderer joins them with the bank-side donations.
|
2026-08-14 02:50:58 +02:00
|
|
|
const Views::RenderedPage page =
|
2026-08-17 11:04:03 +02:00
|
|
|
Views::RenderFinancials(sales.count, sales.totalMinor, fin,
|
|
|
|
|
sales.donationCount, sales.donationsMinor);
|
2026-08-14 02:50:58 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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 = "<!doctype html><title>Moved</title><p>Moved to <a href=\""
|
|
|
|
|
+ route.canonicalRedirect + "\">" + route.canonicalRedirect + "</a>.";
|
|
|
|
|
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,
|
2026-08-10 01:37:26 +02:00
|
|
|
Views::RenderNav(NavKindFor(route.kind)),
|
2026-08-05 04:18:37 +02:00
|
|
|
Views::RenderFooter(),
|
|
|
|
|
wantsWasm ? gBootScripts : std::string_view{},
|
|
|
|
|
gCssHref);
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HTTPResponse ServeSitemap() {
|
|
|
|
|
HTTPResponse res;
|
|
|
|
|
std::string out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
|
|
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
|
|
|
|
for (std::string_view p : SitemapPaths()) {
|
|
|
|
|
out += " <url><loc>https://catcrafts.net";
|
|
|
|
|
out += Html::Escape(p).Str();
|
|
|
|
|
out += "</loc></url>\n";
|
|
|
|
|
}
|
|
|
|
|
// From the catalogue, not a second hardcoded list.
|
|
|
|
|
for (const Product& pr : gContent.products) {
|
|
|
|
|
out += " <url><loc>https://catcrafts.net/shop/";
|
|
|
|
|
out += Html::Escape(pr.slug).Str();
|
|
|
|
|
out += "</loc></url>\n";
|
|
|
|
|
}
|
2026-08-10 01:37:26 +02:00
|
|
|
// 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 += " <url><loc>https://catcrafts.net/posts/";
|
|
|
|
|
out += Html::Escape(po.slug).Str();
|
|
|
|
|
out += "</loc></url>\n";
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
out += "</urlset>\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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
std::mutex gRateMutex;
|
2026-08-13 23:34:19 +02:00
|
|
|
std::deque<RatePoint> gRecentSubmissions;
|
|
|
|
|
std::unordered_map<std::string, std::deque<RatePoint>> 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;
|
2026-08-05 04:18:37 +02:00
|
|
|
constexpr auto kRateWindow = std::chrono::minutes(10);
|
|
|
|
|
|
2026-08-19 23:55:01 +02:00
|
|
|
// A SECOND, tighter budget, for crypto submissions only.
|
|
|
|
|
//
|
|
|
|
|
// Choosing the crypto rail spends a receiving address out of a finite pool
|
|
|
|
|
// that only an offline wallet ceremony can refill, and the address is spent
|
|
|
|
|
// per SUBMISSION rather than per payment — an order nobody ever pays has
|
|
|
|
|
// still consumed one. Under the general budget alone, a stranger needs no
|
|
|
|
|
// account, no card and no money to walk the pool to zero (six per peer is
|
|
|
|
|
// plenty when a default pool is a hundred addresses), and then no buyer can
|
|
|
|
|
// choose crypto until the owner is at a desk with paper.
|
|
|
|
|
//
|
|
|
|
|
// So crypto gets its own smaller allowance on the same window and the same
|
|
|
|
|
// peer key. A real buyer picks crypto once, maybe twice after a mistyped
|
|
|
|
|
// field; nobody legitimately opens six crypto orders in ten minutes. The
|
|
|
|
|
// global leg is the backstop against a spread-out flood, sized so a broad
|
|
|
|
|
// attack costs many addresses rather than the whole pool.
|
|
|
|
|
constexpr std::size_t kMaxCryptoPerPeer = 2;
|
|
|
|
|
constexpr std::size_t kMaxCryptoPerWindow = 20;
|
|
|
|
|
std::deque<RatePoint> gRecentCrypto;
|
|
|
|
|
std::unordered_map<std::string, std::deque<RatePoint>> gRecentCryptoPerPeer;
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
bool RateLimitAllows(std::string_view peer) {
|
2026-08-05 04:18:37 +02:00
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
|
|
|
std::lock_guard lock(gRateMutex);
|
2026-08-13 23:34:19 +02:00
|
|
|
|
|
|
|
|
auto expire = [&](std::deque<RatePoint>& seen) {
|
|
|
|
|
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expire(gRecentSubmissions);
|
2026-08-05 04:18:37 +02:00
|
|
|
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
|
2026-08-13 23:34:19 +02:00
|
|
|
|
|
|
|
|
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<RatePoint>& seen = gRecentPerPeer[std::string(peer)];
|
|
|
|
|
if (seen.size() >= kMaxSubmissionsPerPeer) return false;
|
|
|
|
|
seen.push_back(now);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
gRecentSubmissions.push_back(now);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 23:55:01 +02:00
|
|
|
// The crypto leg of the same limiter, charged only when the buyer picked the
|
|
|
|
|
// rail that spends an address. Deliberately a separate budget rather than a
|
|
|
|
|
// smaller kMaxSubmissionsPerPeer: tightening the general limit would punish
|
|
|
|
|
// the ordinary buyer who fixes a form error, and it is not form errors that
|
|
|
|
|
// exhaust the pool.
|
|
|
|
|
bool CryptoRateLimitAllows(std::string_view peer) {
|
|
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
|
|
|
std::lock_guard lock(gRateMutex);
|
|
|
|
|
|
|
|
|
|
auto expire = [&](std::deque<RatePoint>& seen) {
|
|
|
|
|
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expire(gRecentCrypto);
|
|
|
|
|
if (gRecentCrypto.size() >= kMaxCryptoPerWindow) return false;
|
|
|
|
|
|
|
|
|
|
if (!peer.empty()) {
|
|
|
|
|
// Same leak-avoidance as the general limiter: expire every peer and
|
|
|
|
|
// drop the emptied entries rather than keeping a row per address that
|
|
|
|
|
// ever submitted.
|
|
|
|
|
std::erase_if(gRecentCryptoPerPeer, [&](auto& entry) {
|
|
|
|
|
expire(entry.second);
|
|
|
|
|
return entry.second.empty();
|
|
|
|
|
});
|
|
|
|
|
std::deque<RatePoint>& seen = gRecentCryptoPerPeer[std::string(peer)];
|
|
|
|
|
if (seen.size() >= kMaxCryptoPerPeer) return false;
|
|
|
|
|
seen.push_back(now);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gRecentCrypto.push_back(now);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The inverse of NowIso8601, for the one caller that needs an order's real age:
|
|
|
|
|
// exactly "YYYY-MM-DDTHH:MM:SSZ", which is the only shape this codebase writes.
|
|
|
|
|
// nullopt for anything else — a ledger line from another tool, or a truncated
|
|
|
|
|
// write — so the caller can fall back rather than trust a half-parsed date.
|
|
|
|
|
// (std::chrono::parse would be the obvious tool and is not in this libc++.)
|
|
|
|
|
std::optional<std::chrono::sys_seconds> ParseIso8601Utc(std::string_view s) {
|
|
|
|
|
if (s.size() != 20 || s[4] != '-' || s[7] != '-' || s[10] != 'T'
|
|
|
|
|
|| s[13] != ':' || s[16] != ':' || s[19] != 'Z') {
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
auto num = [&](std::size_t at, std::size_t len) -> std::optional<int> {
|
|
|
|
|
int v = 0;
|
|
|
|
|
const auto [end, ec] =
|
|
|
|
|
std::from_chars(s.data() + at, s.data() + at + len, v);
|
|
|
|
|
if (ec != std::errc{} || end != s.data() + at + len) return std::nullopt;
|
|
|
|
|
return v;
|
|
|
|
|
};
|
|
|
|
|
const auto y = num(0, 4), mo = num(5, 2), d = num(8, 2);
|
|
|
|
|
const auto h = num(11, 2), mi = num(14, 2), sec = num(17, 2);
|
|
|
|
|
if (!y || !mo || !d || !h || !mi || !sec) return std::nullopt;
|
|
|
|
|
if (*mo < 1 || *mo > 12 || *d < 1 || *d > 31) return std::nullopt;
|
|
|
|
|
if (*h > 23 || *mi > 59 || *sec > 60) return std::nullopt;
|
|
|
|
|
const std::chrono::year_month_day ymd{ std::chrono::year{ *y },
|
|
|
|
|
std::chrono::month{
|
|
|
|
|
static_cast<unsigned>(*mo) },
|
|
|
|
|
std::chrono::day{
|
|
|
|
|
static_cast<unsigned>(*d) } };
|
|
|
|
|
if (!ymd.ok()) return std::nullopt;
|
|
|
|
|
return std::chrono::sys_days{ ymd } + std::chrono::hours{ *h }
|
|
|
|
|
+ std::chrono::minutes{ *mi } + std::chrono::seconds{ *sec };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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::seconds>(
|
|
|
|
|
std::chrono::system_clock::now()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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::seconds>(
|
|
|
|
|
std::chrono::system_clock::now()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// POST /shop/<slug> — 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/<token>. 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.
|
2026-08-20 23:33:50 +02:00
|
|
|
// The bunq callback. See ParseBunqCallback for the security model; the short
|
|
|
|
|
// version is that bunq does NOT sign these, so this endpoint's only defences
|
|
|
|
|
// are the ones around it: a secret path segment, a source-IP allowlist in
|
|
|
|
|
// Caddy for bunq's published range, and the fact that no parcel leaves without
|
|
|
|
|
// a human. It therefore does the least it possibly can — decode one payment
|
|
|
|
|
// and append it to the credits file — and makes no settlement decision at all.
|
|
|
|
|
// The reconciler settles from that file exactly as it does from a pulled one,
|
|
|
|
|
// so a forged callback can at worst manufacture a credit line, never bypass
|
|
|
|
|
// the reference match or the covering-amount rule.
|
|
|
|
|
HTTPResponse HandleBunqCallback(const HTTPRequest& req) {
|
|
|
|
|
HTTPResponse res;
|
|
|
|
|
res.headers["content-type"] = "text/plain; charset=utf-8";
|
|
|
|
|
// Never cache, never index, and say nothing useful in the body: this URL
|
|
|
|
|
// is a shared secret, so every answer is the same two characters.
|
|
|
|
|
res.headers["cache-control"] = "no-store";
|
|
|
|
|
res.headers["x-robots-tag"] = "noindex, nofollow";
|
|
|
|
|
|
|
|
|
|
if (req.method != "POST") {
|
|
|
|
|
res.status = "405";
|
|
|
|
|
res.headers["allow"] = "POST";
|
|
|
|
|
res.body = "no\n";
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::optional<BankCredit> credit = ParseBunqCallback(req.body);
|
|
|
|
|
if (!credit) {
|
|
|
|
|
// A callback shape this cannot decode is NOT an error to shout about
|
|
|
|
|
// with a 4xx: bunq sends several notification categories, and only
|
|
|
|
|
// some carry a Payment. Answer 200 so bunq stops retrying something
|
|
|
|
|
// that will never decode, and log it so a genuinely new shape is
|
|
|
|
|
// visible rather than silently dropped.
|
|
|
|
|
std::println(std::cerr, "bunq callback: no usable payment in body ({} bytes)",
|
|
|
|
|
req.body.size());
|
|
|
|
|
res.status = "200";
|
|
|
|
|
res.body = "ok\n";
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Outgoing money cannot pay for an order, and the matcher ignores it
|
|
|
|
|
// anyway — so refuse to write the shop's own supplier payments into a file
|
|
|
|
|
// that lives on a public-facing host.
|
|
|
|
|
if (credit->amountMinor <= 0) {
|
|
|
|
|
res.status = "200";
|
|
|
|
|
res.body = "ok\n";
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (AppendCreditTo(gCreditsPath, *credit)) {
|
|
|
|
|
std::println(std::cerr, "bunq callback: credited {} via {} (id {})",
|
|
|
|
|
Money::FormatMinor(credit->amountMinor), credit->method,
|
|
|
|
|
credit->id);
|
|
|
|
|
}
|
|
|
|
|
// 200 even on a duplicate or a write we skipped: a duplicate IS success
|
|
|
|
|
// from bunq's side, and making it retry would achieve nothing.
|
|
|
|
|
res.status = "200";
|
|
|
|
|
res.body = "ok\n";
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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<Form::FieldError> 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(
|
2026-08-13 23:34:19 +02:00
|
|
|
*product, gContent.rates, shipTable.perCountry, errors, prev,
|
2026-08-20 20:15:47 +02:00
|
|
|
CryptoPaymentAvailable(), BankPaymentAvailable());
|
2026-08-05 04:18:37 +02:00
|
|
|
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
|
|
|
|
Views::RenderFooter(), {}, gCssHref);
|
|
|
|
|
return res;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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);
|
2026-08-05 04:18:37 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
if (!gRails.Any()) {
|
2026-08-05 04:18:37 +02:00
|
|
|
return reject({{ "", "Checkout is offline right now — nothing was charged. "
|
|
|
|
|
"Please try again later." }}, parsed.value, "503");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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)) {
|
2026-08-05 04:18:37 +02:00
|
|
|
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
|
|
|
|
parsed.value, "429");
|
|
|
|
|
}
|
2026-08-19 23:55:01 +02:00
|
|
|
// Crypto pays a second, tighter toll: this submission is about to spend a
|
|
|
|
|
// receiving address that only an offline wallet ceremony can replace. The
|
|
|
|
|
// charge happens here rather than at CreateLink so the general budget is
|
|
|
|
|
// already spent too — a peer probing the pool burns their ordinary
|
|
|
|
|
// checkout allowance at the same time.
|
|
|
|
|
if (wantsCrypto && !CryptoRateLimitAllows(peer)) {
|
|
|
|
|
return reject({{ "pay", "Too many crypto orders from here just now — please "
|
|
|
|
|
"try again shortly, or pick bank or card." }},
|
|
|
|
|
parsed.value, "429");
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
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;
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
2026-08-17 11:04:03 +02:00
|
|
|
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<std::int64_t> 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) }},
|
2026-08-13 23:34:19 +02:00
|
|
|
parsed.value, "422");
|
|
|
|
|
}
|
2026-08-17 11:04:03 +02:00
|
|
|
totals = Money::ComputeTotals(
|
|
|
|
|
unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
|
2026-08-13 23:34:19 +02:00
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-08-17 11:04:03 +02:00
|
|
|
order.donation = product->donation;
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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);
|
2026-08-05 04:18:37 +02:00
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
auto link = rail->CreateLink(
|
2026-08-05 04:18:37 +02:00
|
|
|
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,
|
2026-08-13 23:34:19 +02:00
|
|
|
rail->Name());
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
// Straight to the payment page — the buyer clicked "buy", not "read an
|
|
|
|
|
// interim status page". The order page stays the receipt/status URL that
|
2026-08-20 20:15:47 +02:00
|
|
|
// a hosted provider would redirect back to afterwards.
|
2026-08-05 04:18:37 +02:00
|
|
|
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 = "<!doctype html><title>Order created</title><p>Order created. "
|
|
|
|
|
"<a href=\"" + order.payUrl + "\">Continue to payment</a>.";
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The gate on the order page's arrival poll.
|
|
|
|
|
//
|
|
|
|
|
// Rendering /order/<token> asks the provider whether the payment landed, so a
|
2026-08-20 20:15:47 +02:00
|
|
|
// buyer returning to this page sees "paid" immediately instead of an
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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
|
2026-08-20 20:15:47 +02:00
|
|
|
// live call against the shop's account, and because CreateLink shares
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
|
|
|
|
|
2026-08-20 02:50:28 +02:00
|
|
|
// Orders whose payment the rail has SEEN in flight (visible on the network,
|
|
|
|
|
// finality pending), stamped by whichever poll noticed — the reconciler or an
|
|
|
|
|
// arrival check. Read at render time so the page can acknowledge the money
|
|
|
|
|
// without the render path ever dialing a provider itself: the page shows the
|
|
|
|
|
// most recent poll's knowledge, which is exactly as fresh as "paid" would be.
|
|
|
|
|
// Entries only ever accumulate truth ("seen" is never un-seen by one flaky
|
|
|
|
|
// probe) and age out; a settled or lapsed order stops rendering the section
|
|
|
|
|
// that reads this, so stale entries are harmless.
|
|
|
|
|
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gMoneySeen;
|
|
|
|
|
|
|
|
|
|
void RememberMoneySeen(std::string_view token) {
|
|
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
|
|
|
std::lock_guard lock(gArrivalPollMutex);
|
|
|
|
|
std::erase_if(gMoneySeen, [&](const auto& entry) {
|
|
|
|
|
return now - entry.second > std::chrono::hours(1);
|
|
|
|
|
});
|
|
|
|
|
gMoneySeen[std::string(token)] = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MoneySeenRecently(std::string_view token) {
|
|
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
|
|
|
std::lock_guard lock(gArrivalPollMutex);
|
|
|
|
|
const auto it = gMoneySeen.find(std::string(token));
|
|
|
|
|
// Ten minutes covers several finality epochs; a payment seen longer ago
|
|
|
|
|
// that still has not settled is a claim this page should stop making.
|
|
|
|
|
return it != gMoneySeen.end() && now - it->second < std::chrono::minutes(10);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
|
2026-08-13 23:34:19 +02:00
|
|
|
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<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
|
2026-08-05 04:18:37 +02:00
|
|
|
if (!paid.has_value()) return std::nullopt;
|
2026-08-20 02:50:28 +02:00
|
|
|
if (paid->state == PayState::Pending && paid->seen) {
|
|
|
|
|
RememberMoneySeen(order.token);
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
if (paid->state == PayState::Paid) {
|
|
|
|
|
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
|
|
|
|
// The invoice number exists from the moment the money does —
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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());
|
2026-08-05 04:18:37 +02:00
|
|
|
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 <script> tags Crafter.Build generated into the wasm bundle's
|
|
|
|
|
// index.html, lifted verbatim. They carry a ?v=<buildId> cache buster that
|
|
|
|
|
// changes every build, so hardcoding them here would go stale silently and
|
|
|
|
|
// serve a mismatched module. Extracting them keeps one source of truth.
|
|
|
|
|
if (!bundleIndexHtml.empty()) {
|
|
|
|
|
const std::string index = ReadFile(bundleIndexHtml);
|
|
|
|
|
std::size_t pos = 0;
|
|
|
|
|
while ((pos = index.find("<script", pos)) != std::string::npos) {
|
|
|
|
|
const std::size_t end = index.find("</script>", 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(); }
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
|
|
|
|
gRails = std::move(rails);
|
2026-08-05 04:18:37 +02:00
|
|
|
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 23:33:50 +02:00
|
|
|
void ConfigureBunqCallback(std::filesystem::path creditsPath, std::string secretPath) {
|
|
|
|
|
// Both or neither. A secret with nowhere to write, or a path to write with
|
|
|
|
|
// no secret guarding it, are each worse than not having the endpoint: the
|
|
|
|
|
// first answers requests it cannot act on, the second answers everyone.
|
|
|
|
|
if (creditsPath.empty() || secretPath.empty()) return;
|
|
|
|
|
// A short secret is not a secret. Refuse rather than serve a guessable
|
|
|
|
|
// write endpoint just because someone set the variable to "hook".
|
|
|
|
|
if (secretPath.size() < 24) {
|
|
|
|
|
std::println(std::cerr,
|
|
|
|
|
"bunq callback: BUNQ_WEBHOOK_PATH is too short to be secret "
|
|
|
|
|
"({} chars, want 24+) — the endpoint is NOT enabled",
|
|
|
|
|
secretPath.size());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (secretPath.front() != '/') secretPath.insert(secretPath.begin(), '/');
|
|
|
|
|
gCreditsPath = std::move(creditsPath);
|
|
|
|
|
gWebhookPath = std::move(secretPath);
|
|
|
|
|
// Deliberately does NOT log the path: it would land in the journal, and
|
|
|
|
|
// from there in any log shipping or analytics that reads it.
|
|
|
|
|
std::println(std::cerr, "bunq callback: enabled on a secret path ({} chars)",
|
|
|
|
|
gWebhookPath.size());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
bool BankPaymentAvailable() { return gRails.bank != nullptr; }
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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.
|
|
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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
|
2026-08-15 00:54:05 +02:00
|
|
|
// the request volume the slower one was promised. `first` drives the age backoff,
|
2026-08-13 23:34:19 +02:00
|
|
|
// `last` enforces the interval; keeping them apart also retires the modulo
|
|
|
|
|
// pacing that used to approximate this with one.
|
2026-08-05 04:18:37 +02:00
|
|
|
void ReconcilerLoop(const std::stop_token& stop) {
|
2026-08-13 23:34:19 +02:00
|
|
|
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<std::string, Seen> seen;
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
while (!stop.stop_requested()) {
|
2026-08-13 23:34:19 +02:00
|
|
|
std::this_thread::sleep_for(SweepInterval());
|
2026-08-05 04:18:37 +02:00
|
|
|
if (stop.stop_requested()) break;
|
|
|
|
|
|
|
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
|
|
|
for (const OrderRecord& order : ListOrders()) {
|
|
|
|
|
if (order.status != "awaiting_payment") {
|
2026-08-13 23:34:19 +02:00
|
|
|
seen.erase(order.token);
|
2026-08-05 04:18:37 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// Age from the record's own timestamp is string math we don't
|
|
|
|
|
// need: steady-clock first-seen is good enough for backoff.
|
2026-08-13 23:34:19 +02:00
|
|
|
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now });
|
2026-08-05 04:18:37 +02:00
|
|
|
if (!inserted) {
|
|
|
|
|
using namespace std::chrono;
|
2026-08-19 23:55:01 +02:00
|
|
|
// Age from the ORDER, not from when this process first saw it.
|
|
|
|
|
// Steady-clock first-seen restarts the seven days on every
|
|
|
|
|
// deploy, so a year-old awaiting order gets polled for another
|
|
|
|
|
// week after each one — wasted calls against both providers,
|
|
|
|
|
// growing with every abandoned order the ledger has ever held.
|
|
|
|
|
// The record's timestamp is the real age; a timestamp that will
|
|
|
|
|
// not parse falls back to the old behaviour rather than
|
|
|
|
|
// dropping an order that might be live.
|
|
|
|
|
const std::optional<std::chrono::sys_seconds> placed =
|
|
|
|
|
ParseIso8601Utc(order.createdAt);
|
|
|
|
|
const auto age =
|
|
|
|
|
placed ? std::chrono::duration_cast<
|
|
|
|
|
std::chrono::steady_clock::duration>(
|
|
|
|
|
std::chrono::system_clock::now() - *placed)
|
|
|
|
|
: now - it->second.first;
|
2026-08-13 23:34:19 +02:00
|
|
|
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;
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Paid, lapsed (the provider says the payment can never arrive),
|
|
|
|
|
// or nothing to report — the shared step handles the transition.
|
2026-08-13 23:34:19 +02:00
|
|
|
if (PollAndAdvance(order)) seen.erase(order.token);
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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.
|
2026-08-09 00:14:09 +02:00
|
|
|
OrderRecord o = order;
|
2026-08-17 11:04:03 +02:00
|
|
|
if (!o.donation && o.invoiceNumber.empty()) {
|
2026-08-09 00:14:09 +02:00
|
|
|
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.
|
2026-08-17 11:04:03 +02:00
|
|
|
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;
|
2026-08-09 00:14:09 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<std::string, Attempt> 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;
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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;
|
2026-08-09 00:14:09 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
} // 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/<slug> and /order/<token>
|
|
|
|
|
// need no listener change when they arrive — they are just more paths
|
|
|
|
|
// ParseRoute already knows about.
|
|
|
|
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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 {
|
2026-08-20 23:33:50 +02:00
|
|
|
// The bunq callback, before anything else looks at the path. It is
|
|
|
|
|
// matched here rather than added to the shared route table on purpose:
|
|
|
|
|
// ParseRoute is shared with the wasm frontend, and a secret URL has no
|
|
|
|
|
// business being compiled into a bundle served to browsers.
|
|
|
|
|
//
|
|
|
|
|
// Compared in CONSTANT TIME. This is a shared secret in a URL, so a
|
|
|
|
|
// timing oracle on a byte-by-byte compare would let it be recovered
|
|
|
|
|
// one character at a time — and unlike a password there is no rate
|
|
|
|
|
// limit or lockout behind it.
|
|
|
|
|
// POST only, and non-POST deliberately falls through to ordinary page
|
|
|
|
|
// handling rather than answering 405. A distinctive answer here would
|
|
|
|
|
// be an oracle: a GET returning 405 where every other unknown URL
|
|
|
|
|
// returns 404 confirms a guessed path is the right one, which is
|
|
|
|
|
// exactly the signal a secret-in-the-URL scheme cannot afford to give.
|
|
|
|
|
// bunq only ever POSTs, so nothing legitimate is lost.
|
|
|
|
|
if (!gWebhookPath.empty() && req.method == "POST") {
|
|
|
|
|
const std::string_view path = PathWithoutQueryHTTP(req.path);
|
|
|
|
|
if (path.size() == gWebhookPath.size()) {
|
|
|
|
|
unsigned char diff = 0;
|
|
|
|
|
for (std::size_t i = 0; i < path.size(); ++i) {
|
|
|
|
|
diff |= static_cast<unsigned char>(path[i] ^ gWebhookPath[i]);
|
|
|
|
|
}
|
|
|
|
|
if (diff == 0) return HandleBunqCallback(req);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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<std::jthread> reconciler;
|
2026-08-13 23:34:19 +02:00
|
|
|
if (gRails.Any()) {
|
2026-08-05 04:18:37 +02:00
|
|
|
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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<std::jthread> mailer;
|
|
|
|
|
if (MailConfigured()) {
|
|
|
|
|
mailer.emplace([](std::stop_token st) { MailerLoop(st); });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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));
|
2026-08-19 23:55:01 +02:00
|
|
|
// std::cerr like every other diagnostic, and not for consistency alone:
|
|
|
|
|
// under journald stdout is a pipe, so it is FULLY buffered — this line
|
|
|
|
|
// once sat invisible for hours (or died unflushed with the process) while
|
|
|
|
|
// deploy tooling polled the journal for it as a liveness signal. stderr
|
|
|
|
|
// is unbuffered; the one line that announces what the server IS must not
|
|
|
|
|
// arrive after the fact.
|
|
|
|
|
std::println(std::cerr,
|
|
|
|
|
"catcrafts-server: listening on 127.0.0.1:{} "
|
2026-08-13 23:34:19 +02:00
|
|
|
"({} projects, {} posts, payments: bank={} crypto={})",
|
2026-08-05 04:18:37 +02:00
|
|
|
port, gContent.projects.size(), gContent.posts.size(),
|
2026-08-13 23:34:19 +02:00
|
|
|
gRails.bank ? gRails.bank->Name() : "off",
|
|
|
|
|
gRails.crypto ? gRails.crypto->Name() : "off");
|
2026-08-05 04:18:37 +02:00
|
|
|
listener.Listen();
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace Catcrafts::Server
|