This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -34,6 +34,33 @@ using namespace Crafter;
|
|||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
// Request provenance. Both are pure and declared in the module interface,
|
||||
// where the reasoning for each lives; the self-test drives them directly.
|
||||
|
||||
std::string_view ClientAddressFromForwarded(std::string_view forwarded) {
|
||||
// The RIGHTMOST entry, because that is the one Caddy appended. Anything to
|
||||
// its left is whatever the client felt like claiming.
|
||||
const std::size_t comma = forwarded.rfind(',');
|
||||
const std::string_view last = comma == std::string_view::npos
|
||||
? forwarded
|
||||
: forwarded.substr(comma + 1);
|
||||
return Form::Trim(last);
|
||||
}
|
||||
|
||||
bool OriginAllowed(std::string_view origin, std::string_view redirectBase) {
|
||||
if (origin.empty()) return true; // not a browser form post; see the header
|
||||
// A trailing slash is legal in a configured base and never present in an
|
||||
// Origin header, so normalise both ends rather than depend on the operator.
|
||||
auto trim = [](std::string_view s) {
|
||||
while (!s.empty() && s.back() == '/') s.remove_suffix(1);
|
||||
return s;
|
||||
};
|
||||
const std::string_view want = trim(redirectBase);
|
||||
// An unconfigured base must not silently accept every origin.
|
||||
if (want.empty()) return false;
|
||||
return trim(origin) == want;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Loaded once at startup. The content files are generated at build time (CI
|
||||
|
|
@ -43,11 +70,28 @@ 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;
|
||||
// The payment rails, installed by ConfigurePayments before Serve; two null
|
||||
// rails mean checkout answers 503 rather than creating orders nothing can pay.
|
||||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// The 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;
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
|
|
@ -64,6 +108,7 @@ struct AdvanceResult {
|
|||
std::string paidVia;
|
||||
};
|
||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
||||
std::string NowIso8601();
|
||||
|
||||
// Common headers on every HTML response.
|
||||
|
|
@ -104,12 +149,15 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
// 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).
|
||||
// backend-down fallback uses with no rate table at all, and therefore
|
||||
// quotes no totals: with the zone fallback gone there is nothing for it to
|
||||
// price from, which is correct — that path cannot reach checkout either).
|
||||
if (route.kind == RouteKind::Product) {
|
||||
if (const Product* product = gContent.FindProduct(route.slug)) {
|
||||
const ShippingTable ship = CurrentShippingTable();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry);
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
|
||||
{}, {}, CryptoPaymentAvailable());
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||
|
|
@ -197,15 +245,24 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
}
|
||||
|
||||
// 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.
|
||||
// the provider — but the reconciler may not have polled yet. Ask the
|
||||
// rail right now so the page they land on already says paid, instead
|
||||
// of an alarming "awaiting payment" that flips ten seconds later.
|
||||
// Still the poll-is-truth rule: this trusts the provider's
|
||||
// authenticated answer, never the fact of being redirected.
|
||||
//
|
||||
// Gated to one call per token per rail interval — see
|
||||
// ArrivalPollAllowed. A reload past that renders from the ledger and
|
||||
// lets the reconciler do its job, which is the whole point of having
|
||||
// one. The interval is the ORDER'S rail's, so a crypto order is not
|
||||
// paced by Mollie's cadence or the other way round.
|
||||
if (order->status == "awaiting_payment") {
|
||||
if (const auto advanced = PollAndAdvance(*order)) {
|
||||
order->status = advanced->status;
|
||||
order->paidVia = advanced->paidVia;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +270,7 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
view.token = order->token;
|
||||
view.reference = order->reference;
|
||||
view.status = order->status;
|
||||
view.payChoice = order->payChoice;
|
||||
view.payUrl = order->payUrl;
|
||||
view.createdAt = order->createdAt;
|
||||
view.country = order->buyer.country;
|
||||
|
|
@ -331,29 +389,66 @@ HTTPResponse ServeFeed() {
|
|||
return res;
|
||||
}
|
||||
|
||||
// A very coarse rate limit on checkout submissions.
|
||||
// Rate limiting on checkout submissions: per-peer first, global as a backstop.
|
||||
//
|
||||
// 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.
|
||||
// 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 intent is only to stop a script filling the file overnight; the honeypot
|
||||
// handles ordinary bots and Caddy handles volume.
|
||||
// The real peer IS knowable here, carefully: Caddy appends it to
|
||||
// X-Forwarded-For, so the rightmost entry is Caddy's own word rather than the
|
||||
// client's (see ClientAddressFromForwarded, which is where that reasoning
|
||||
// lives). It is trustworthy only because nothing else can reach this listener.
|
||||
//
|
||||
// So the per-peer cap is the actual control, and the global cap stays purely
|
||||
// as a runaway backstop — set high enough that it is not a lever one peer can
|
||||
// pull, since tripping it still denies everyone. A flood broad enough to reach
|
||||
// it is an infrastructure problem, and belongs to Caddy and the host.
|
||||
//
|
||||
// Unproxied requests (dev, e2e, a direct curl at the loopback port) carry no
|
||||
// X-Forwarded-For. They are charged to the global budget only — there is no
|
||||
// peer to key on, and inventing one would be a lie.
|
||||
using RatePoint = std::chrono::steady_clock::time_point;
|
||||
|
||||
std::mutex gRateMutex;
|
||||
std::deque<std::chrono::steady_clock::time_point> gRecentSubmissions;
|
||||
constexpr std::size_t kMaxSubmissionsPerWindow = 30;
|
||||
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;
|
||||
constexpr auto kRateWindow = std::chrono::minutes(10);
|
||||
|
||||
bool RateLimitAllows() {
|
||||
bool RateLimitAllows(std::string_view peer) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gRateMutex);
|
||||
while (!gRecentSubmissions.empty() && now - gRecentSubmissions.front() > kRateWindow) {
|
||||
gRecentSubmissions.pop_front();
|
||||
}
|
||||
|
||||
auto expire = [&](std::deque<RatePoint>& seen) {
|
||||
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
||||
};
|
||||
|
||||
expire(gRecentSubmissions);
|
||||
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
|
||||
|
||||
if (!peer.empty()) {
|
||||
// Expire every peer, not just this one, and drop those whose window has
|
||||
// emptied: otherwise the map keeps one entry per address that ever
|
||||
// submitted, which is a slow leak an attacker chooses the rate of.
|
||||
std::erase_if(gRecentPerPeer, [&](auto& entry) {
|
||||
expire(entry.second);
|
||||
return entry.second.empty();
|
||||
});
|
||||
std::deque<RatePoint>& seen = gRecentPerPeer[std::string(peer)];
|
||||
if (seen.size() >= kMaxSubmissionsPerPeer) return false;
|
||||
seen.push_back(now);
|
||||
}
|
||||
|
||||
gRecentSubmissions.push_back(now);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -409,12 +504,25 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
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);
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev,
|
||||
CryptoPaymentAvailable());
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
};
|
||||
|
||||
// Cross-site request forgery. This POST creates an order and calls the
|
||||
// payment provider, so it must have come from our own form — a page on
|
||||
// another origin must not be able to drive it with a visitor's browser.
|
||||
// OriginAllowed carries the reasoning, including why a MISSING Origin is
|
||||
// accepted (a non-browser client cannot forge cross-site).
|
||||
if (const auto origin = req.headers.find("origin"); origin != req.headers.end()) {
|
||||
if (!OriginAllowed(origin->second, gRedirectBase)) {
|
||||
return reject({{ "", "That submission didn't come from this site. "
|
||||
"Nothing was charged." }}, {}, "403");
|
||||
}
|
||||
}
|
||||
|
||||
// Only urlencoded — the form sends nothing else, and accepting more content
|
||||
// types means parsing more attacker-chosen formats for no benefit.
|
||||
const auto ct = req.headers.find("content-type");
|
||||
|
|
@ -443,12 +551,35 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
parsed.value, "409");
|
||||
}
|
||||
|
||||
if (!gRail) {
|
||||
if (!gRails.Any()) {
|
||||
return reject({{ "", "Checkout is offline right now — nothing was charged. "
|
||||
"Please try again later." }}, parsed.value, "503");
|
||||
}
|
||||
|
||||
if (!RateLimitAllows()) {
|
||||
// The rail the buyer picked. ValidateCheckout has already refused anything
|
||||
// that is not one of the two words, so what remains is the case where the
|
||||
// word is valid but its slot is not configured — a form cached from before
|
||||
// the rail was switched off, or a hand-made post. Say which one is missing
|
||||
// rather than "checkout is offline": the other method is right there and
|
||||
// still works.
|
||||
const bool wantsCrypto = parsed.value.payChoice == Form::kPayCrypto;
|
||||
PaymentRail* rail = gRails.For(parsed.value.payChoice);
|
||||
if (!rail) {
|
||||
return reject({{ "pay", wantsCrypto
|
||||
? "Crypto payment isn't available right now — nothing "
|
||||
"was charged. Please pick bank or card."
|
||||
: "Bank and card payment isn't available right now — "
|
||||
"nothing was charged. Please pick crypto." }},
|
||||
parsed.value, "503");
|
||||
}
|
||||
|
||||
// Charged to this peer's own budget, so a flood costs the flooder their
|
||||
// checkout and nobody else theirs.
|
||||
std::string_view peer;
|
||||
if (const auto fwd = req.headers.find("x-forwarded-for"); fwd != req.headers.end()) {
|
||||
peer = ClientAddressFromForwarded(fwd->second);
|
||||
}
|
||||
if (!RateLimitAllows(peer)) {
|
||||
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
||||
parsed.value, "429");
|
||||
}
|
||||
|
|
@ -472,12 +603,38 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
// 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);
|
||||
// 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) }},
|
||||
parsed.value, "422");
|
||||
}
|
||||
const Money::Totals totals = Money::ComputeTotals(
|
||||
unitMinor, parsed.value.quantity, shippingMinor, parsed.value.country);
|
||||
unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
|
||||
|
||||
OrderRecord order;
|
||||
order.token = NewOrderToken();
|
||||
|
|
@ -492,8 +649,11 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
order.shippingMinor = totals.shipping;
|
||||
order.totalMinor = totals.total;
|
||||
order.vatIncluded = totals.vatIncluded;
|
||||
// Normalised, not echoed: the record must name the rail that issued the
|
||||
// link, and an empty submitted choice took the bank rail above.
|
||||
order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank);
|
||||
|
||||
auto link = gRail->CreateLink(
|
||||
auto link = rail->CreateLink(
|
||||
order.totalMinor,
|
||||
std::format("{} catcrafts.net", order.reference),
|
||||
std::format("{}/order/{}", gRedirectBase, order.token));
|
||||
|
|
@ -517,7 +677,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
std::println(std::cerr, "order {} created: {} {} -> {}", order.reference,
|
||||
Money::FormatMinor(order.totalMinor), order.buyer.country,
|
||||
gRail->Name());
|
||||
rail->Name());
|
||||
|
||||
// Straight to the payment page — the buyer clicked "buy", not "read an
|
||||
// interim status page". The order page stays the receipt/status URL that
|
||||
|
|
@ -531,12 +691,60 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
return res;
|
||||
}
|
||||
|
||||
// The gate on the order page's arrival poll.
|
||||
//
|
||||
// Rendering /order/<token> asks the provider whether the payment landed, so a
|
||||
// buyer redirected back from Mollie sees "paid" immediately instead of an
|
||||
// alarming "awaiting payment" that flips ten seconds later. That is a good
|
||||
// thing to do once. The problem was that it happened on EVERY render: an
|
||||
// outbound HTTPS round trip, on the request thread, holding the rail's mutex,
|
||||
// reachable as often as anyone cared to reload.
|
||||
//
|
||||
// The hole that closes: an attacker places one order — their own, so no token
|
||||
// guessing is involved — and then reloads it in a loop. Every reload spent a
|
||||
// live Mollie API call against the shop's key, and because CreateLink shares
|
||||
// that same mutex, real buyers' checkouts queued behind the flood. The
|
||||
// listener is thread-per-connection with no cap, so the blocked threads piled
|
||||
// up as well.
|
||||
//
|
||||
// One poll per token per rail interval is all the arrival check ever needed.
|
||||
// Its job is to beat the reconciler to the FIRST render, not to become a
|
||||
// second reconciler — everything after that is the reconciler's work, and it
|
||||
// already paces itself by order age. Tying the gate to the rail's own cadence
|
||||
// keeps the two honest about each other: the fake rail's one-second interval
|
||||
// leaves dev and the e2e suite behaving exactly as before.
|
||||
std::mutex gArrivalPollMutex;
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
||||
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gArrivalPollMutex);
|
||||
// Orders settle or lapse; their entries should not outlive them. An hour
|
||||
// idle is far past both, and pruning here keeps the map bounded by live
|
||||
// traffic rather than by every token ever viewed.
|
||||
std::erase_if(gLastArrivalPoll, [&](const auto& entry) {
|
||||
return now - entry.second > std::chrono::hours(1);
|
||||
});
|
||||
const auto [it, inserted] = gLastArrivalPoll.try_emplace(std::string(token), now);
|
||||
if (inserted) return true;
|
||||
if (now - it->second < interval) return false;
|
||||
it->second = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// One reconciliation step for one order: ask the rail, append the transition
|
||||
// if there is one, and report the order's (possibly new) status fields.
|
||||
// Shared by the reconciler thread and the order page's on-arrival check.
|
||||
std::optional<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 (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);
|
||||
if (!paid.has_value()) return std::nullopt;
|
||||
if (paid->state == PayState::Paid) {
|
||||
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
||||
|
|
@ -627,11 +835,13 @@ 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);
|
||||
void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
||||
gRails = std::move(rails);
|
||||
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
||||
}
|
||||
|
||||
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
||||
|
||||
namespace {
|
||||
|
||||
// The reconciler: the ONLY thing that moves an order to paid.
|
||||
|
|
@ -641,45 +851,58 @@ namespace {
|
|||
// 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).
|
||||
// Poll pacing backs off with order age — a buyer mid-flow gets answers at
|
||||
// their provider's own cadence, a two-hour-old order drops to every 10
|
||||
// minutes, and after seven days it stops being polled (a very late payment is
|
||||
// then found by the manual CLI path, which exists for exactly that).
|
||||
//
|
||||
// Two timestamps per order rather than one. The sweep runs at the FASTEST
|
||||
// configured rail's cadence, because that rail's orders deserve it, so
|
||||
// "poll on every pass" would silently poll the slower provider at the faster
|
||||
// one's rate — with two rails that is no longer a rounding error but double
|
||||
// the request volume CoinGate was promised. `first` drives the age backoff,
|
||||
// `last` enforces the interval; keeping them apart also retires the modulo
|
||||
// pacing that used to approximate this with one.
|
||||
void ReconcilerLoop(const std::stop_token& stop) {
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastPoll;
|
||||
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;
|
||||
|
||||
while (!stop.stop_requested()) {
|
||||
std::this_thread::sleep_for(gRail->PollInterval());
|
||||
std::this_thread::sleep_for(SweepInterval());
|
||||
if (stop.stop_requested()) break;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
for (const OrderRecord& order : ListOrders()) {
|
||||
if (order.status != "awaiting_payment") {
|
||||
lastPoll.erase(order.token);
|
||||
seen.erase(order.token);
|
||||
continue;
|
||||
}
|
||||
// The order's OWN provider: the two rails ask to be polled at
|
||||
// different rates, and a sweep running at the faster one's cadence
|
||||
// must not push the slower one.
|
||||
const PaymentRail* rail = gRails.For(order.payChoice);
|
||||
if (!rail) continue;
|
||||
|
||||
// Age from the record's own timestamp is string math we don't
|
||||
// need: steady-clock first-seen is good enough for backoff.
|
||||
auto [it, inserted] = lastPoll.try_emplace(order.token, now);
|
||||
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, 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;
|
||||
}
|
||||
const auto age = now - it->second.first;
|
||||
if (age > hours(24 * 7)) continue;
|
||||
const auto due = age > hours(2)
|
||||
? seconds(minutes(10))
|
||||
: rail->PollInterval();
|
||||
if (now - it->second.last < due) continue;
|
||||
it->second.last = now;
|
||||
}
|
||||
|
||||
// Paid, lapsed (the provider says the payment can never arrive),
|
||||
// or nothing to report — the shared step handles the transition.
|
||||
if (PollAndAdvance(order)) lastPoll.erase(order.token);
|
||||
if (PollAndAdvance(order)) seen.erase(order.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -832,7 +1055,7 @@ int Serve(std::uint16_t port) {
|
|||
// 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) {
|
||||
if (gRails.Any()) {
|
||||
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
||||
}
|
||||
|
||||
|
|
@ -859,9 +1082,10 @@ int Serve(std::uint16_t port) {
|
|||
|
||||
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
|
||||
std::println("catcrafts-server: listening on 127.0.0.1:{} "
|
||||
"({} projects, {} posts, payments: {})",
|
||||
"({} projects, {} posts, payments: bank={} crypto={})",
|
||||
port, gContent.projects.size(), gContent.posts.size(),
|
||||
gRail ? gRail->Name() : "off");
|
||||
gRails.bank ? gRails.bank->Name() : "off",
|
||||
gRails.crypto ? gRails.crypto->Name() : "off");
|
||||
listener.Listen();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue