This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
|
|
@ -75,6 +75,12 @@ std::string gCssHref = "/styles.css";
|
|||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// The bank-derived aggregates for /financials live in Catcrafts.Server-
|
||||
// Financials.cpp, which owns their file and the bunq callback that updates
|
||||
// them. They are read through CurrentFinancials() per request rather than
|
||||
// cached: unlike the content files they CAN change under a running process,
|
||||
// and live is the page's whole promise.
|
||||
|
||||
// The reconciler's sweep cadence: the shortest interval any configured rail
|
||||
// asks for. Each order is still paced by ITS OWN rail's interval inside the
|
||||
// loop — a shared sweep that ran at the slower rail's pace would make the
|
||||
|
|
@ -92,6 +98,12 @@ std::chrono::seconds SweepInterval() {
|
|||
return out;
|
||||
}
|
||||
|
||||
// The callback URL's fixed prefix; everything after it is the shared secret.
|
||||
// Under /api because Caddy proxies that prefix straight through and the
|
||||
// analytics ingest censors it out of the public report (deploy/README.md) —
|
||||
// a URL carrying a secret must not end up on a page anyone can read.
|
||||
inline constexpr std::string_view kBunqCallbackPrefix = "/api/bunq/";
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
|
|
@ -316,6 +328,27 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
return res;
|
||||
}
|
||||
|
||||
// The financials page: lifetime sales folded live from the order ledger,
|
||||
// donations and expenses from the bank-aggregates file. Server-rendered
|
||||
// here because both inputs are runtime state; the shared dispatch's case
|
||||
// is the backend-down fallback, like orders. Refolding the ledger per
|
||||
// request is what every order lookup already does, and the page's whole
|
||||
// promise is that a refresh shows the current totals — so no caching.
|
||||
if (route.kind == RouteKind::Financials) {
|
||||
const std::vector<OrderRecord> orders = ListOrders();
|
||||
const SalesSummary sales = SummarizeSales(orders);
|
||||
const Financials fin = CurrentFinancials();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderFinancials(sales.count, sales.totalMinor, fin);
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false,
|
||||
page.meta.noindex);
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Financials),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
}
|
||||
|
||||
const Views::RenderedPage page = Views::RenderRoute(route, gContent);
|
||||
|
||||
HTTPResponse res;
|
||||
|
|
@ -1027,6 +1060,48 @@ int Serve(std::uint16_t port) {
|
|||
};
|
||||
|
||||
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
|
||||
// The bunq mutation callback. Handled here rather than through
|
||||
// ParseRoute because the path carries a SECRET — the shared route
|
||||
// table is compiled into the wasm bundle that ships to every browser,
|
||||
// and a secret has no business being in it.
|
||||
//
|
||||
// Everything unauthorised answers 404, never 401: the endpoint should
|
||||
// not confirm its own existence to a prober, exactly as an unknown
|
||||
// order token does not confirm the shape of a real one.
|
||||
if (const std::string_view path = PathWithoutQueryHTTP(req.path);
|
||||
path.starts_with(kBunqCallbackPrefix)) {
|
||||
HTTPResponse res;
|
||||
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||
res.headers["cache-control"] = "no-store";
|
||||
res.headers["x-robots-tag"] = "noindex, nofollow";
|
||||
const std::string_view secret = path.substr(kBunqCallbackPrefix.size());
|
||||
if (!BunqCallbackConfigured() || req.method != "POST"
|
||||
|| req.body.size() > Form::kMaxBodyBytes) {
|
||||
res.status = "404";
|
||||
res.body = "Not found\n";
|
||||
return res;
|
||||
}
|
||||
std::string_view signature;
|
||||
if (const auto h = req.headers.find("x-bunq-server-signature");
|
||||
h != req.headers.end()) {
|
||||
signature = h->second;
|
||||
}
|
||||
if (!BunqCallbackAuthorised(secret, req.body, signature)) {
|
||||
res.status = "404";
|
||||
res.body = "Not found\n";
|
||||
return res;
|
||||
}
|
||||
// 200 for everything the endpoint understood, including a
|
||||
// withheld or duplicate mutation: those are correct outcomes, and
|
||||
// a non-2xx would make bunq redeliver a callback that was already
|
||||
// handled exactly as intended. Only a failed WRITE earns a 500,
|
||||
// because a retry of that genuinely could succeed.
|
||||
const BunqIngestResult result = IngestBunqNotification(req.body);
|
||||
res.status = result == BunqIngestResult::Failed ? "500" : "200";
|
||||
res.body = result == BunqIngestResult::Failed ? "Could not record\n" : "OK\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
// A POST to a product page is a checkout submission.
|
||||
if (req.method == "POST") {
|
||||
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
||||
|
|
|
|||
Loading…
Reference in a new issue