catcrafts.net/server/implementations/Catcrafts.Server-Http.cpp
Jorijn van der Graaf 2fa6e70af1
All checks were successful
Deploy / build-deploy (push) Successful in 6m14s
order confirmation
2026-08-09 00:14:09 +02:00

861 lines
38 KiB
C++

/*
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 {
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 rail, installed by ConfigurePayments before Serve; a null rail
// means checkout answers 503 rather than creating orders nothing can pay.
std::unique_ptr<PaymentRail> gRail;
std::string gRedirectBase = "https://catcrafts.net";
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);
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 the zone table only).
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);
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);
if (!order || (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<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
// Mollie — 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 Mollie's authenticated answer, never
// the fact of being redirected.
if (order->status == "awaiting_payment") {
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.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.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;
}
// 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;
}
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,
Views::RenderNav(route.kind == RouteKind::LegacyBlog
? RouteKind::Posts : route.kind),
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";
}
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;
}
// A very coarse rate limit on checkout submissions.
//
// Not a general-purpose limiter, and deliberately not per-IP: the server sits
// behind Caddy, so every request arrives from 127.0.0.1 unless forwarding
// headers are trusted — and trusting a client-settable header for rate limiting
// is worse than not limiting at all. So this is a global cap, which is the
// honest thing a reverse-proxied process can enforce by itself. Per-IP limiting
// belongs in Caddy, where the real peer address lives.
//
// The intent is only to stop a script filling the file overnight; the honeypot
// handles ordinary bots and Caddy handles volume.
std::mutex gRateMutex;
std::deque<std::chrono::steady_clock::time_point> gRecentSubmissions;
constexpr std::size_t kMaxSubmissionsPerWindow = 30;
constexpr auto kRateWindow = std::chrono::minutes(10);
bool RateLimitAllows() {
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gRateMutex);
while (!gRecentSubmissions.empty() && now - gRecentSubmissions.front() > kRateWindow) {
gRecentSubmissions.pop_front();
}
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
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::seconds>(
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::seconds>(
std::chrono::system_clock::now()));
}
// 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.
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(
*product, gContent.rates, shipTable.perCountry, errors, prev);
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
Views::RenderFooter(), {}, gCssHref);
return res;
};
// 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");
}
Form::CheckoutResult parsed = 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 (!gRail) {
return reject({{ "", "Checkout is offline right now — nothing was charged. "
"Please try again later." }}, parsed.value, "503");
}
if (!RateLimitAllows()) {
return reject({{ "", "Too many submissions just now — please try again shortly." }},
parsed.value, "429");
}
// 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;
}
const std::int64_t 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.
const std::int64_t shippingMinor = ShipCostFor(
parsed.value.country, product->shipNlMinor, product->shipEuMinor,
product->shipWorldMinor);
const Money::Totals 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;
auto link = gRail->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,
gRail->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 = "<!doctype html><title>Order created</title><p>Order created. "
"<a href=\"" + order.payUrl + "\">Continue to payment</a>.";
return res;
}
// 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) {
if (!gRail || order.status != "awaiting_payment") return std::nullopt;
const std::optional<PaidStatus> paid = gRail->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.
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 <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(); }
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase) {
gRail = std::move(rail);
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
}
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 in
// seconds, a day-old order gets checked hourly, and after seven days the
// order stops being polled (a very late payment is then found by the manual
// CLI path, which exists for exactly that).
void ReconcilerLoop(const std::stop_token& stop) {
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastPoll;
while (!stop.stop_requested()) {
std::this_thread::sleep_for(gRail->PollInterval());
if (stop.stop_requested()) break;
const auto now = std::chrono::steady_clock::now();
for (const OrderRecord& order : ListOrders()) {
if (order.status != "awaiting_payment") {
lastPoll.erase(order.token);
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] = lastPoll.try_emplace(order.token, now);
if (!inserted) {
const auto sinceFirst = now - it->second;
// it->second tracks FIRST time seen; store poll pacing in a
// parallel structure? One map is enough: after the first
// pass, re-poll every interval for 2 h, then only every
// 10 min, dropping to nothing after 7 days.
using namespace std::chrono;
if (sinceFirst > hours(24 * 7)) continue;
if (sinceFirst > hours(2)) {
// Coarse modulo pacing: only act on passes that land in
// the first interval of every 10-minute window.
const auto inWindow = duration_cast<seconds>(sinceFirst) % minutes(10);
if (inWindow > gRail->PollInterval() * 2) continue;
}
}
// Paid, lapsed (the provider says the payment can never arrive),
// or nothing to report — the shared step handles the transition.
if (PollAndAdvance(order)) lastPoll.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.
OrderRecord o = order;
if (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 = 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<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;
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/<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 {
// 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;
if (gRail) {
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<std::jthread> 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: {})",
port, gContent.projects.size(), gContent.posts.size(),
gRail ? gRail->Name() : "off");
listener.Listen();
return 0;
}
} // namespace Catcrafts::Server