This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
562
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
562
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
/*
|
||||
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 payment rails: bunq (real money) and fake (tests).
|
||||
//
|
||||
// The bunq client speaks the v1 REST API over Crafter.Network's ClientHTTP1
|
||||
// with TLS — no SDK, because the four calls this needs (installation,
|
||||
// device-server, session-server, bunqme-tab) do not justify a dependency, and
|
||||
// every byte in and out goes through the same strict JSON reader as the rest
|
||||
// of the site.
|
||||
//
|
||||
// Context (RSA key, installation token, session token, ids) persists in ONE
|
||||
// JSON file under the service's StateDirectory — never in the repo, never in
|
||||
// the web root. Delete the file and the client re-onboards from the API key.
|
||||
//
|
||||
// A word on trust direction: this code never treats an inbound signal as
|
||||
// authoritative. The ?status= on bunq's redirect back to the order page is
|
||||
// ignored entirely; an order becomes paid ONLY when an authenticated GET to
|
||||
// bunq's API says the tab's payments cover the amount. That is the poll — the
|
||||
// reconciler in Catcrafts.Server-Http.cpp drives it.
|
||||
//
|
||||
// Request signing: bunq stopped REQUIRING body signatures in 2019, but the
|
||||
// keypair exists anyway (installation demands a public key), signing is ~40
|
||||
// lines, and a signed request is valid whether or not the server checks. So
|
||||
// every body is signed — X-Bunq-Client-Signature, RSA-SHA256 over the raw
|
||||
// body, base64.
|
||||
|
||||
module;
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rsa.h>
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// ── small pure helpers ────────────────────────────────────────────────
|
||||
|
||||
std::string Base64(std::span<const unsigned char> in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((in.size() + 2) / 3) * 4);
|
||||
std::size_t i = 0;
|
||||
for (; i + 2 < in.size(); i += 3) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||
}
|
||||
if (i + 1 == in.size()) {
|
||||
const std::uint32_t n = in[i] << 16;
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += "==";
|
||||
} else if (i + 2 == in.size()) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JsonEscapeB(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string RandomHex(std::size_t words) {
|
||||
std::random_device rd;
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the first object under any key in bunq's Response array:
|
||||
// {"Response":[{"Id":{...}},{"Token":{...}}]}
|
||||
const Json::Value* FindInResponse(const Json::Value& doc, std::string_view key) {
|
||||
const Json::Value* resp = doc.Find("Response");
|
||||
if (!resp || !resp->IsArray()) return nullptr;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
if (!item.IsObject()) continue;
|
||||
if (const Json::Value* v = item.Find(key)) return v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||
// spaces, thousands separators — is rejected. Money parsing has no
|
||||
// "probably fine" mode.
|
||||
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||
std::int64_t units = 0;
|
||||
std::size_t i = 0;
|
||||
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
units = units * 10 + (s[i] - '0');
|
||||
}
|
||||
std::int64_t cents = 0;
|
||||
if (i < s.size()) {
|
||||
if (s[i] != '.') return std::nullopt;
|
||||
++i;
|
||||
const std::size_t fracStart = i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
cents = cents * 10 + (s[i] - '0');
|
||||
}
|
||||
const std::size_t digits = i - fracStart;
|
||||
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||
if (digits == 1) cents *= 10;
|
||||
}
|
||||
return units * 100 + cents;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// ── the fake rail ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||
// which the test creates when it wants "the customer has paid" to happen.
|
||||
|
||||
class FakeRail final : public PaymentRail {
|
||||
public:
|
||||
explicit FakeRail(std::filesystem::path marker) : marker_(std::move(marker)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||
const std::string& redirectUrl) override {
|
||||
static std::atomic<std::int64_t> counter{1};
|
||||
PaymentLink link;
|
||||
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||
// send anyone to, so it points at the order page itself — which keeps
|
||||
// the browser flow usable in dev and the e2e redirect parseable.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||
std::error_code ec;
|
||||
return PaidStatus{
|
||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||
"fake" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "fake"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||
|
||||
private:
|
||||
std::filesystem::path marker_;
|
||||
};
|
||||
|
||||
// ── the bunq rail ─────────────────────────────────────────────────────
|
||||
|
||||
class BunqRail final : public PaymentRail {
|
||||
public:
|
||||
explicit BunqRail(RailConfig cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
host_(cfg_.sandbox ? "public-api.sandbox.bunq.com" : "api.bunq.com") {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
const std::string body = std::format(
|
||||
R"({{"bunqme_tab_entry":{{"amount_inquired":{{"value":"{}","currency":"EUR"}},)"
|
||||
R"("description":"{}","redirect_url":"{}"}}}})",
|
||||
Money::FormatMinor(amountMinor), JsonEscapeB(description),
|
||||
JsonEscapeB(redirectUrl));
|
||||
|
||||
auto doc = Call("POST", TabsPath(), body);
|
||||
if (!doc) return std::nullopt;
|
||||
const Json::Value* id = FindInResponse(*doc, "Id");
|
||||
if (!id) return std::nullopt;
|
||||
const std::int64_t tabId = id->Int("id");
|
||||
if (tabId <= 0) return std::nullopt;
|
||||
|
||||
// The POST answers with the id only; the share URL comes from a GET.
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
const std::string url(bmt->Str("bunqme_tab_share_url"));
|
||||
if (url.empty()) return std::nullopt;
|
||||
|
||||
PaymentLink link;
|
||||
link.payId = std::to_string(tabId);
|
||||
link.payUrl = url;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// The id is a bunq tab number that travelled through our ledger.
|
||||
std::int64_t tabId = 0;
|
||||
auto [ptr, ec] = std::from_chars(payId.data(), payId.data() + payId.size(), tabId);
|
||||
if (ec != std::errc{} || ptr != payId.data() + payId.size() || tabId <= 0) {
|
||||
return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
|
||||
// Sum every settled inquiry on the tab. A tab accepts unlimited
|
||||
// payments until cancelled, so the question is "do the payments cover
|
||||
// the amount", not "is there a payment".
|
||||
std::int64_t paid = 0;
|
||||
if (const Json::Value* inquiries = bmt->Find("result_inquiries");
|
||||
inquiries && inquiries->IsArray()) {
|
||||
for (const Json::Value& entry : inquiries->array) {
|
||||
if (!entry.IsObject()) continue;
|
||||
const Json::Value* payment = entry.Find("payment");
|
||||
if (payment && payment->IsObject()) {
|
||||
if (const Json::Value* inner = payment->Find("Payment");
|
||||
inner && inner->IsObject()) payment = inner;
|
||||
}
|
||||
if (!payment) continue;
|
||||
const Json::Value* amount = payment->Find("amount");
|
||||
if (!amount || !amount->IsObject()) continue;
|
||||
if (amount->Str("currency") != "EUR") continue;
|
||||
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||
paid += *minor;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A bunq tab never dies on its own — it accepts payments until
|
||||
// cancelled — so the only states here are Paid and Pending. The
|
||||
// method is not identified per payment; "bunq" is honest enough.
|
||||
return PaidStatus{ paid >= expectedMinor ? PayState::Paid : PayState::Pending,
|
||||
"bunq" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "bunq"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(15); }
|
||||
|
||||
private:
|
||||
// ── context persistence ───────────────────────────────────────────
|
||||
|
||||
void LoadState() {
|
||||
std::ifstream in(cfg_.statePath, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
auto doc = Json::Parse(buf.str());
|
||||
if (!doc || !doc->IsObject()) return;
|
||||
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
|
||||
installationToken_ = std::string(doc->Str("installation_token"));
|
||||
deviceRegistered_ = doc->Bool("device_registered");
|
||||
sessionToken_ = std::string(doc->Str("session_token"));
|
||||
userId_ = doc->Int("user_id");
|
||||
accountId_ = doc->Int("account_id");
|
||||
}
|
||||
|
||||
bool SaveState() {
|
||||
// 0600 before content: the file holds the private key.
|
||||
std::ofstream out(cfg_.statePath, std::ios::trunc | std::ios::binary);
|
||||
if (!out) return false;
|
||||
out << std::format(
|
||||
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
||||
R"("device_registered":{},"session_token":"{}","user_id":{},"account_id":{}}})",
|
||||
JsonEscapeB(privateKeyPem_), JsonEscapeB(installationToken_),
|
||||
deviceRegistered_, JsonEscapeB(sessionToken_), userId_, accountId_);
|
||||
out.flush();
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(cfg_.statePath,
|
||||
std::filesystem::perms::owner_read
|
||||
| std::filesystem::perms::owner_write,
|
||||
ec);
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
// ── crypto ────────────────────────────────────────────────────────
|
||||
|
||||
bool EnsureKeypair() {
|
||||
if (!privateKeyPem_.empty()) return LoadKey();
|
||||
EVP_PKEY* raw = EVP_RSA_gen(2048);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return false;
|
||||
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) {
|
||||
BIO_free(bio);
|
||||
return false;
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return SaveState();
|
||||
}
|
||||
|
||||
bool LoadKey() {
|
||||
if (key_) return true;
|
||||
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
|
||||
static_cast<int>(privateKeyPem_.size()));
|
||||
if (!bio) return false;
|
||||
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
|
||||
BIO_free(bio);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PublicKeyPem() {
|
||||
if (!LoadKey()) return {};
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return {};
|
||||
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
|
||||
BIO_free(bio);
|
||||
return {};
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
std::string pem(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return pem;
|
||||
}
|
||||
|
||||
std::string SignBody(std::string_view body) {
|
||||
if (!LoadKey()) return {};
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) return {};
|
||||
std::string out;
|
||||
do {
|
||||
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, key_.get()) != 1) break;
|
||||
std::size_t len = 0;
|
||||
if (EVP_DigestSign(ctx, nullptr, &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
std::vector<unsigned char> sig(len);
|
||||
if (EVP_DigestSign(ctx, sig.data(), &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
sig.resize(len);
|
||||
out = Base64(sig);
|
||||
} while (false);
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── transport ─────────────────────────────────────────────────────
|
||||
|
||||
// One HTTPS call, returning the parsed JSON on 2xx. On 401 with a live
|
||||
// session the caller decides whether to re-session; this layer only
|
||||
// reports. Network and TLS failures land as nullopt — the reconciler
|
||||
// treats that as "unknown, retry later", never as "unpaid".
|
||||
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
|
||||
const std::string& body, const std::string& authToken,
|
||||
std::string* statusOut = nullptr) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
host_, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = host_;
|
||||
req.body = body;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["cache-control"] = "no-cache";
|
||||
req.headers["x-bunq-client-request-id"] = RandomHex(4);
|
||||
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
|
||||
req.headers["x-bunq-language"] = "en_US";
|
||||
req.headers["x-bunq-region"] = "nl_NL";
|
||||
if (!body.empty()) {
|
||||
req.headers["content-type"] = "application/json";
|
||||
const std::string sig = SignBody(body);
|
||||
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
|
||||
}
|
||||
if (!authToken.empty()) {
|
||||
req.headers["x-bunq-client-authentication"] = authToken;
|
||||
}
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (statusOut) *statusOut = res.status;
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path, res.status,
|
||||
res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
auto doc = Json::Parse(res.body);
|
||||
if (!doc) return std::nullopt;
|
||||
return std::move(*doc);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// A session-authenticated call, with one automatic re-session on 401 —
|
||||
// sessions expire server-side and that must not surface as a failure.
|
||||
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
std::string status;
|
||||
auto doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
if (!doc && status == "401") {
|
||||
sessionToken_.clear();
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── onboarding ────────────────────────────────────────────────────
|
||||
//
|
||||
// installation (once, ever) -> installation token
|
||||
// device-server (once, ever) -> binds the API key to this "device"
|
||||
// session-server (per session) -> session token + user id
|
||||
// monetary-account (once) -> account to attach tabs to
|
||||
//
|
||||
// All idempotent to re-run individually; state records how far we got.
|
||||
|
||||
bool EnsureSession() {
|
||||
if (!loaded_) { LoadState(); loaded_ = true; }
|
||||
if (cfg_.apiKey.empty()) {
|
||||
std::println(std::cerr, "bunq: no API key configured");
|
||||
return false;
|
||||
}
|
||||
if (!EnsureKeypair()) return false;
|
||||
|
||||
if (installationToken_.empty()) {
|
||||
const std::string body =
|
||||
std::format(R"({{"client_public_key":"{}"}})", JsonEscapeB(PublicKeyPem()));
|
||||
auto doc = DoCall("POST", "/v1/installation", body, {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
installationToken_ = std::string(token->Str("token"));
|
||||
if (installationToken_.empty()) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (!deviceRegistered_) {
|
||||
// permitted_ips "*": this box sits on a residential connection
|
||||
// whose address changes; pinning the current IP would brick the
|
||||
// integration on the next DHCP lease. The API key secret still
|
||||
// gates everything.
|
||||
const std::string body = std::format(
|
||||
R"({{"description":"catcrafts.net server","secret":"{}","permitted_ips":["*"]}})",
|
||||
JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/device-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
deviceRegistered_ = true;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (sessionToken_.empty() || userId_ == 0) {
|
||||
const std::string body =
|
||||
std::format(R"({{"secret":"{}"}})", JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/session-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
sessionToken_ = std::string(token->Str("token"));
|
||||
// The user object's key varies by account type; take whichever came.
|
||||
for (std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
||||
if (const Json::Value* u = FindInResponse(*doc, k)) {
|
||||
userId_ = u->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionToken_.empty() || userId_ == 0) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (accountId_ == 0) {
|
||||
auto doc = Call("GET",
|
||||
std::format("/v1/user/{}/monetary-account?count=25", userId_), {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* resp = doc->Find("Response");
|
||||
if (!resp || !resp->IsArray()) return false;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
||||
if (acc && acc->Str("status") == "ACTIVE") {
|
||||
accountId_ = acc->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (accountId_ == 0) {
|
||||
std::println(std::cerr, "bunq: no active MonetaryAccountBank found");
|
||||
return false;
|
||||
}
|
||||
SaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string TabsPath() const {
|
||||
return std::format("/v1/user/{}/monetary-account/{}/bunqme-tab", userId_, accountId_);
|
||||
}
|
||||
|
||||
struct PkeyDeleter {
|
||||
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||
};
|
||||
|
||||
RailConfig cfg_;
|
||||
std::string host_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
||||
bool loaded_ = false;
|
||||
std::string privateKeyPem_;
|
||||
std::string installationToken_;
|
||||
bool deviceRegistered_ = false;
|
||||
std::string sessionToken_;
|
||||
std::int64_t userId_ = 0;
|
||||
std::int64_t accountId_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The roster itself (MakeRail) lives in the Mollie unit; these two factories
|
||||
// keep FakeRail/BunqRail construction next to their definitions.
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config) {
|
||||
return std::make_unique<FakeRail>(config.statePath);
|
||||
}
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config) {
|
||||
return std::make_unique<BunqRail>(config);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
742
server/implementations/Catcrafts.Server-Http.cpp
Normal file
742
server/implementations/Catcrafts.Server-Http.cpp
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
/*
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // 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); });
|
||||
}
|
||||
|
||||
// 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
|
||||
186
server/implementations/Catcrafts.Server-Invoice.cpp
Normal file
186
server/implementations/Catcrafts.Server-Invoice.cpp
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Invoices: markdown, clearsigned with GPG.
|
||||
//
|
||||
// Markdown because an invoice's job is to be READ — by the buyer, by an
|
||||
// accountant, by a tax office, in thirty years, with any text editor. A
|
||||
// clearsigned document keeps the text human-readable with the signature
|
||||
// inline (gpg --verify checks it), so authenticity does not depend on this
|
||||
// server still existing — which is the point: the buyer downloads the file
|
||||
// once and the shop makes no promise to host receipt pages forever.
|
||||
//
|
||||
// Signing shells out to the gpg binary rather than linking a PGP library:
|
||||
// the key management story (GNUPGHOME, agent, key generation) is exactly the
|
||||
// part a library reimplements badly, and the server signs a handful of
|
||||
// documents per week. The subprocess writes to files under a private
|
||||
// directory, never a shell-interpolated user string — the only variable in
|
||||
// the command line is the key id, validated to a safe alphabet.
|
||||
|
||||
module;
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string gGpgKeyId;
|
||||
|
||||
// The registered business identity. On every invoice — these are the fields
|
||||
// a Dutch invoice must carry along with the sequential number and amounts.
|
||||
constexpr std::string_view kSellerName = "Catcrafts";
|
||||
constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
|
||||
constexpr std::string_view kSellerCity = "3315NN Dordrecht";
|
||||
constexpr std::string_view kSellerKvk = "78437059";
|
||||
constexpr std::string_view kSellerVat = "NL003329281B38";
|
||||
constexpr std::string_view kSellerSite = "catcrafts.net";
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string BuildInvoiceMarkdown(const OrderRecord& o,
|
||||
std::string_view productName,
|
||||
std::string_view colorLabel) {
|
||||
std::string md;
|
||||
md.reserve(2048);
|
||||
|
||||
const std::string item = colorLabel.empty()
|
||||
? std::string(productName)
|
||||
: std::format("{} — {}", productName, colorLabel);
|
||||
|
||||
// The number scheme continues the pre-shop administration: the customer
|
||||
// number is a UUID series, the invoice number counts within it.
|
||||
const std::size_t dash = o.invoiceNumber.size() > 37 ? 36 : std::string::npos;
|
||||
const std::string customer = dash != std::string::npos
|
||||
? o.invoiceNumber.substr(0, 36) : o.invoiceNumber;
|
||||
const std::string seq = dash != std::string::npos
|
||||
? o.invoiceNumber.substr(37) : o.invoiceNumber;
|
||||
|
||||
md += std::format("# Invoice {}\n\n", o.invoiceNumber);
|
||||
md += std::format("**{}** \n{} \n{} \nKVK {} · VAT {} · {}\n\n",
|
||||
kSellerName, kSellerStreet, kSellerCity,
|
||||
kSellerKvk, kSellerVat, kSellerSite);
|
||||
// "*" bullets, never "-": clearsigning dash-escapes lines that start
|
||||
// with a dash ("- - Invoice date"), and the raw file is meant to be read.
|
||||
md += std::format("* Customer number: {}\n", customer);
|
||||
md += std::format("* Invoice number: {}\n", seq);
|
||||
md += std::format("* Invoice date: {}\n", o.invoicedAt);
|
||||
md += std::format("* Order reference: {}\n", o.reference);
|
||||
md += std::format("* Order placed: {}\n", o.createdAt);
|
||||
if (!o.paidVia.empty()) {
|
||||
md += std::format("* Paid via: {}\n", o.paidVia);
|
||||
}
|
||||
md += "\n## Billed and shipped to\n\n";
|
||||
md += std::format("{} \n{} \n{} {} \n{}\n\n",
|
||||
o.buyer.name, o.buyer.street, o.buyer.postal,
|
||||
o.buyer.city, o.buyer.country);
|
||||
|
||||
md += "## Amounts\n\n";
|
||||
md += "| Description | Qty | Amount |\n|---|---|---|\n";
|
||||
if (o.vatIncluded) {
|
||||
// EU supply: net amounts per line, VAT once over the taxable total —
|
||||
// the same line-total rounding the checkout charged with.
|
||||
const std::int64_t net = Money::NetFromGross(o.totalMinor);
|
||||
const std::int64_t vat = o.totalMinor - net;
|
||||
md += std::format("| {} | {} | {} |\n", item, o.quantity,
|
||||
Money::FormatEuro(Money::NetFromGross(o.goodsMinor)));
|
||||
md += std::format("| Shipping | 1 | {} |\n",
|
||||
Money::FormatEuro(Money::NetFromGross(o.shippingMinor)));
|
||||
md += std::format("| Subtotal (ex VAT) | | {} |\n", Money::FormatEuro(net));
|
||||
md += std::format("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat));
|
||||
md += std::format("| **Total (incl. VAT)** | | **{}** |\n",
|
||||
Money::FormatEuro(o.totalMinor));
|
||||
} else {
|
||||
md += std::format("| {} | {} | {} |\n", item, o.quantity,
|
||||
Money::FormatEuro(o.goodsMinor));
|
||||
md += std::format("| Shipping | 1 | {} |\n",
|
||||
Money::FormatEuro(o.shippingMinor));
|
||||
md += std::format("| **Total** | | **{}** |\n",
|
||||
Money::FormatEuro(o.totalMinor));
|
||||
md += "\nVAT 0%: zero-rated export outside the EU "
|
||||
"(art. 146 EU VAT Directive). Import duties and taxes are levied "
|
||||
"by the destination country and are not part of this invoice.\n";
|
||||
}
|
||||
|
||||
md += "\nThis invoice was generated by catcrafts.net and signed with the "
|
||||
"shop's GPG key. Verify with: gpg --verify <this file>\n";
|
||||
return md;
|
||||
}
|
||||
|
||||
void ConfigureInvoicing(std::string gpgKeyId) {
|
||||
// The key id ends up on a command line — constrain it to the alphabet a
|
||||
// fingerprint or uid email actually needs, and refuse anything else.
|
||||
for (const char c : gpgKeyId) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '@' || c == '.'
|
||||
|| c == '_' || c == '-' || c == '+';
|
||||
if (!ok) {
|
||||
std::println(std::cerr,
|
||||
"invoice: refusing GPG key id with unexpected characters");
|
||||
return;
|
||||
}
|
||||
}
|
||||
gGpgKeyId = std::move(gpgKeyId);
|
||||
if (!gGpgKeyId.empty()) {
|
||||
std::println(std::cerr, "invoice: signing with GPG key '{}'", gGpgKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
bool InvoiceSigningConfigured() { return !gGpgKeyId.empty(); }
|
||||
|
||||
std::optional<std::string> ClearsignInvoice(const std::string& markdown) {
|
||||
if (gGpgKeyId.empty()) return std::nullopt;
|
||||
|
||||
std::error_code ec;
|
||||
const std::filesystem::path dir =
|
||||
std::filesystem::temp_directory_path(ec) / "catcrafts-invoice";
|
||||
if (ec) return std::nullopt;
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
std::filesystem::permissions(dir, std::filesystem::perms::owner_all, ec);
|
||||
|
||||
// Distinct per call so concurrent downloads cannot collide.
|
||||
static std::atomic<std::uint64_t> counter{1};
|
||||
const std::uint64_t n = counter.fetch_add(1);
|
||||
const std::filesystem::path in = dir / std::format("in-{}.md", n);
|
||||
const std::filesystem::path out = dir / std::format("out-{}.md.asc", n);
|
||||
|
||||
{
|
||||
std::ofstream f(in, std::ios::trunc | std::ios::binary);
|
||||
if (!f) return std::nullopt;
|
||||
f << markdown;
|
||||
if (!f.flush()) return std::nullopt;
|
||||
}
|
||||
|
||||
// --batch: never prompt (the service has no terminal). The key must be
|
||||
// passphrase-free or preset in the agent — deploy/README.md covers it.
|
||||
const std::string cmd = std::format(
|
||||
"gpg --batch --yes --clearsign --local-user '{}' -o '{}' '{}' 2>/dev/null",
|
||||
gGpgKeyId, out.string(), in.string());
|
||||
const int rc = std::system(cmd.c_str());
|
||||
|
||||
std::string signedText;
|
||||
if (rc == 0) {
|
||||
std::ifstream f(out, std::ios::binary);
|
||||
std::ostringstream buf;
|
||||
buf << f.rdbuf();
|
||||
signedText = buf.str();
|
||||
} else {
|
||||
std::println(std::cerr, "invoice: gpg clearsign failed (rc {})", rc);
|
||||
}
|
||||
std::filesystem::remove(in, ec);
|
||||
std::filesystem::remove(out, ec);
|
||||
|
||||
if (signedText.empty()) return std::nullopt;
|
||||
return signedText;
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
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 Mollie payment rail.
|
||||
//
|
||||
// Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on
|
||||
// cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not
|
||||
// a checkout). Mollie is a Dutch licensed PSP built for exactly this size of
|
||||
// shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a
|
||||
// hosted checkout so card data never touches this server.
|
||||
//
|
||||
// The API is refreshingly small next to bunq's: one bearer-token key, no
|
||||
// RSA signing, no session dance.
|
||||
//
|
||||
// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
|
||||
// GET /v2/payments/{id} -> status, method
|
||||
//
|
||||
// Trust direction is unchanged from the design rule: the ?redirect back to
|
||||
// the order page is ignored; an order becomes paid ONLY when an authenticated
|
||||
// GET says status=paid with a covering amount. One deliberate difference from
|
||||
// the bunq tab model: a Mollie payment can EXPIRE (canceled/expired/failed are
|
||||
// terminal), so the poll distinguishes Pending / Paid / Dead and the
|
||||
// reconciler lapses orders whose payment can never arrive.
|
||||
//
|
||||
// A test API key (test_…) works against the real endpoints from the moment a
|
||||
// Mollie account is created — verify with that before going live; unlike the
|
||||
// bunq client this one need not ship on faith.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string JsonEscapeM(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
||||
MolliePayment p;
|
||||
p.id = std::string(doc->Str("id"));
|
||||
p.status = std::string(doc->Str("status"));
|
||||
p.method = std::string(doc->Str("method"));
|
||||
if (p.id.empty() || p.status.empty()) return std::nullopt;
|
||||
|
||||
if (const Json::Value* amount = doc->Find("amount"); amount && amount->IsObject()) {
|
||||
// Only euro amounts are ever created, so anything else failing to
|
||||
// parse to zero is the safe outcome — a zero amount never satisfies
|
||||
// an order total.
|
||||
if (amount->Str("currency") == "EUR") {
|
||||
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||
p.amountMinor = *minor;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (const Json::Value* links = doc->Find("_links"); links && links->IsObject()) {
|
||||
if (const Json::Value* checkout = links->Find("checkout");
|
||||
checkout && checkout->IsObject()) {
|
||||
p.checkoutUrl = std::string(checkout->Str("href"));
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class MollieRail final : public PaymentRail {
|
||||
public:
|
||||
explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
const std::string body = std::format(
|
||||
R"({{"amount":{{"currency":"EUR","value":"{}"}},)"
|
||||
R"("description":"{}","redirectUrl":"{}"}})",
|
||||
Money::FormatMinor(amountMinor), JsonEscapeM(description),
|
||||
JsonEscapeM(redirectUrl));
|
||||
|
||||
const std::optional<std::string> res = Call("POST", "/v2/payments", body);
|
||||
if (!res) return std::nullopt;
|
||||
const auto payment = ParseMolliePayment(*res);
|
||||
if (!payment || payment->checkoutUrl.empty()) {
|
||||
std::println(std::cerr, "mollie: create returned no checkout url");
|
||||
return std::nullopt;
|
||||
}
|
||||
PaymentLink link;
|
||||
link.payId = payment->id;
|
||||
link.payUrl = payment->checkoutUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// The id came from Mollie, but it travels through our ledger — keep
|
||||
// the path composition strict anyway.
|
||||
for (const char c : payId) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '_';
|
||||
if (!ok) return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
|
||||
const std::optional<std::string> res = Call("GET", "/v2/payments/" + payId, {});
|
||||
if (!res) return std::nullopt;
|
||||
const auto payment = ParseMolliePayment(*res);
|
||||
if (!payment) return std::nullopt;
|
||||
|
||||
PaidStatus out;
|
||||
out.method = payment->method;
|
||||
if (payment->status == "paid" && payment->amountMinor >= expectedMinor) {
|
||||
out.state = PayState::Paid;
|
||||
} else if (payment->status == "canceled" || payment->status == "expired"
|
||||
|| payment->status == "failed") {
|
||||
out.state = PayState::Dead;
|
||||
} else {
|
||||
// open / pending / authorized — still in flight.
|
||||
out.state = PayState::Pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "mollie"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(10); }
|
||||
|
||||
private:
|
||||
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
|
||||
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
|
||||
std::optional<std::string> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
"api.mollie.com", static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = "api.mollie.com";
|
||||
req.body = body;
|
||||
req.headers["authorization"] = "Bearer " + cfg_.apiKey;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
if (!body.empty()) req.headers["content-type"] = "application/json";
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "mollie: {} {} -> {} {}", method, path,
|
||||
res.status, res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "mollie: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Defined here rather than in the bunq unit so the rail roster has one home;
|
||||
// the bunq and fake constructors are declared by their own units.
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config);
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config);
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
|
||||
if (config.mode == "fake") return MakeFakeRail(config);
|
||||
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
|
||||
if (config.mode == "bunq") return MakeBunqRail(config);
|
||||
return nullptr; // "off"
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
301
server/implementations/Catcrafts.Server-Orders.cpp
Normal file
301
server/implementations/Catcrafts.Server-Orders.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Order storage: an append-only JSON-lines event log.
|
||||
//
|
||||
// Two event types share the file:
|
||||
//
|
||||
// {"type":"order", ...full record...} written once, at checkout
|
||||
// {"type":"status", "id":..,"status":..} one per transition
|
||||
//
|
||||
// Current state is a left fold over the file; later events win. Nothing is
|
||||
// ever rewritten, so the log doubles as the audit trail the tax records need,
|
||||
// and a crash mid-write costs at most its own line (a truncated last line is
|
||||
// skipped by the reader, not fatal).
|
||||
//
|
||||
// Why not SQLite yet: single-digit orders per week, one writer, no relations.
|
||||
// The day volume proves that wrong, this imports into a database in one
|
||||
// sitting. What this file holds is personal data (name, address, email), so
|
||||
// the same rules as ever: 0600 via the service's umask, off the web root,
|
||||
// encrypted before any backup leaves the machine.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gOrdersMutex;
|
||||
std::filesystem::path gOrdersPath;
|
||||
|
||||
// Minimal JSON string escaping. Values were validated upstream, but they are
|
||||
// still user input, and a raw newline or quote would corrupt the
|
||||
// line-per-record format — silently truncating the data on the next read.
|
||||
std::string JsonEscape(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool AppendLine(const std::string& line) {
|
||||
if (gOrdersPath.empty()) return false;
|
||||
// Open per append: orders arrive rarely, and a file reopened each time can
|
||||
// be rotated or edited underneath the running process without a restart.
|
||||
std::ofstream out(gOrdersPath, std::ios::app | std::ios::binary);
|
||||
if (!out) return false;
|
||||
out << line << '\n';
|
||||
out.flush();
|
||||
// Report the stream state: a full disk must surface as a visible error,
|
||||
// not a payment link over an order that was never recorded.
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
// Fold the whole log into id -> record. Corrupt lines are skipped — one bad
|
||||
// line must not take the rest of the ledger with it.
|
||||
std::vector<OrderRecord> FoldLocked() {
|
||||
std::vector<OrderRecord> out;
|
||||
if (gOrdersPath.empty()) return out;
|
||||
std::ifstream in(gOrdersPath, std::ios::binary);
|
||||
if (!in) return out;
|
||||
|
||||
auto find = [&](std::string_view token) -> OrderRecord* {
|
||||
for (OrderRecord& r : out) {
|
||||
if (r.token == token) return &r;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
auto doc = Json::Parse(line);
|
||||
if (!doc || !doc->IsObject()) continue;
|
||||
const std::string_view type = doc->Str("type");
|
||||
if (type == "order") {
|
||||
OrderRecord r;
|
||||
r.token = std::string(doc->Str("id"));
|
||||
r.reference = std::string(doc->Str("ref"));
|
||||
r.product = std::string(doc->Str("product"));
|
||||
r.color = std::string(doc->Str("color"));
|
||||
r.quantity = doc->Int("quantity", 1);
|
||||
r.unitMinor = doc->Int("unit_minor");
|
||||
r.createdAt = std::string(doc->Str("at"));
|
||||
r.updatedAt = r.createdAt;
|
||||
r.buyer.email = std::string(doc->Str("email"));
|
||||
r.buyer.name = std::string(doc->Str("name"));
|
||||
r.buyer.street = std::string(doc->Str("street"));
|
||||
r.buyer.postal = std::string(doc->Str("postal"));
|
||||
r.buyer.city = std::string(doc->Str("city"));
|
||||
r.buyer.country = std::string(doc->Str("country"));
|
||||
r.goodsMinor = doc->Int("goods_minor");
|
||||
r.shippingMinor = doc->Int("shipping_minor");
|
||||
r.totalMinor = doc->Int("total_minor");
|
||||
r.vatIncluded = doc->Bool("vat_included");
|
||||
r.status = std::string(doc->Str("status", "awaiting_payment"));
|
||||
r.payUrl = std::string(doc->Str("pay_url"));
|
||||
r.payId = std::string(doc->Str("pay_id"));
|
||||
if (r.token.empty()) continue;
|
||||
// A duplicate "order" event for an id would be a writer bug; first
|
||||
// one wins so a replayed line cannot rewrite history.
|
||||
if (!find(r.token)) out.push_back(std::move(r));
|
||||
} else if (type == "invoice") {
|
||||
OrderRecord* r = find(doc->Str("id"));
|
||||
if (!r) continue;
|
||||
r->invoiceNumber = std::string(doc->Str("number"));
|
||||
r->invoicedAt = std::string(doc->Str("at"));
|
||||
} else if (type == "status") {
|
||||
OrderRecord* r = find(doc->Str("id"));
|
||||
if (!r) continue; // status for an unknown order: skip, keep folding
|
||||
const std::string_view status = doc->Str("status");
|
||||
if (status.empty()) continue;
|
||||
r->status = std::string(status);
|
||||
r->updatedAt = std::string(doc->Str("at"));
|
||||
if (const std::string_view via = doc->Str("via"); !via.empty()) {
|
||||
r->paidVia = std::string(via);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SetOrdersPath(const std::filesystem::path& p) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
gOrdersPath = p;
|
||||
}
|
||||
|
||||
bool CreateOrder(const OrderRecord& o) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
return AppendLine(std::format(
|
||||
R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)"
|
||||
R"("color":"{}","quantity":{},"unit_minor":{},)"
|
||||
R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
|
||||
R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},)"
|
||||
R"("status":"{}","pay_url":"{}","pay_id":"{}"}})",
|
||||
JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference),
|
||||
JsonEscape(o.product),
|
||||
JsonEscape(o.color), o.quantity, o.unitMinor,
|
||||
JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street),
|
||||
JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country),
|
||||
o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded,
|
||||
JsonEscape(o.status), JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
||||
}
|
||||
|
||||
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||
std::string_view isoTimestamp, std::string_view via) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
if (via.empty()) {
|
||||
return AppendLine(std::format(
|
||||
R"({{"type":"status","at":"{}","id":"{}","status":"{}"}})",
|
||||
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status)));
|
||||
}
|
||||
return AppendLine(std::format(
|
||||
R"({{"type":"status","at":"{}","id":"{}","status":"{}","via":"{}"}})",
|
||||
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status),
|
||||
JsonEscape(via)));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Case-normalised email: the customer key. Good enough on purpose — a person
|
||||
// with two addresses is two customers, exactly as they would be in the manual
|
||||
// administration this scheme continues.
|
||||
std::string CustomerKey(std::string_view email) {
|
||||
std::string out(email);
|
||||
for (char& c : out) {
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A random v4 UUID — the customer number, matching the pre-shop invoice
|
||||
// administration (folders named by customer UUID, invoices <uuid>-<n>).
|
||||
std::string NewCustomerUuid() {
|
||||
std::random_device rd;
|
||||
std::array<std::uint32_t, 4> w{ rd(), rd(), rd(), rd() };
|
||||
auto* b = reinterpret_cast<unsigned char*>(w.data());
|
||||
b[6] = static_cast<unsigned char>((b[6] & 0x0f) | 0x40); // version 4
|
||||
b[8] = static_cast<unsigned char>((b[8] & 0x3f) | 0x80); // variant 10
|
||||
std::string out;
|
||||
out.reserve(36);
|
||||
static constexpr char hex[] = "0123456789abcdef";
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
if (i == 4 || i == 6 || i == 8 || i == 10) out += '-';
|
||||
out += hex[b[i] >> 4];
|
||||
out += hex[b[i] & 0xf];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
||||
std::string_view isoTimestamp) {
|
||||
// Numbering continues the shop owner's existing administration: one
|
||||
// SERIES PER CUSTOMER (a random UUID as customer number), sequential
|
||||
// within it — "f57c6512-…-3" is that customer's third invoice. Multiple
|
||||
// series are what art. 226(2)'s "one or more series" permits, and the
|
||||
// append-only ledger plus the payment provider's records carry the
|
||||
// completeness proof an auditor actually wants.
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
const OrderRecord* target = nullptr;
|
||||
std::vector<OrderRecord> all = FoldLocked();
|
||||
for (const OrderRecord& r : all) {
|
||||
if (r.token == token) { target = &r; break; }
|
||||
}
|
||||
if (!target) return std::nullopt;
|
||||
// Idempotent: a paid order re-processed (manual CLI after the reconciler,
|
||||
// say) keeps its number — a sequence never burns a member on a retry.
|
||||
if (!target->invoiceNumber.empty()) return target->invoiceNumber;
|
||||
|
||||
// The customer's existing series, if any: same email (case-normalised),
|
||||
// highest sequence. Invoice numbers are "<uuid(36)>-<seq>".
|
||||
const std::string key = CustomerKey(target->buyer.email);
|
||||
std::string customer;
|
||||
std::int64_t maxSeq = 0;
|
||||
for (const OrderRecord& r : all) {
|
||||
if (r.invoiceNumber.size() < 38 || CustomerKey(r.buyer.email) != key) continue;
|
||||
customer = r.invoiceNumber.substr(0, 36);
|
||||
std::int64_t seq = 0;
|
||||
const char* b = r.invoiceNumber.data() + 37;
|
||||
std::from_chars(b, r.invoiceNumber.data() + r.invoiceNumber.size(), seq);
|
||||
maxSeq = std::max(maxSeq, seq);
|
||||
}
|
||||
if (customer.empty()) customer = NewCustomerUuid();
|
||||
|
||||
const std::string number = std::format("{}-{}", customer, maxSeq + 1);
|
||||
if (!AppendLine(std::format(
|
||||
R"({{"type":"invoice","at":"{}","id":"{}","number":"{}","customer":"{}"}})",
|
||||
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(number),
|
||||
JsonEscape(customer)))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
std::optional<OrderRecord> FindOrder(std::string_view token) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
for (OrderRecord& r : FoldLocked()) {
|
||||
if (r.token == token) return std::move(r);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<OrderRecord> ListOrders() {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
return FoldLocked();
|
||||
}
|
||||
|
||||
std::string NewOrderToken() {
|
||||
// std::random_device on this platform reads the kernel CSPRNG. The token
|
||||
// gates access to a name and address, so 128 bits — the same order of
|
||||
// unguessability as a session cookie.
|
||||
std::random_device rd;
|
||||
std::string out;
|
||||
out.reserve(32);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const std::uint32_t w = rd();
|
||||
out += std::format("{:08x}", w);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ReferenceFromToken(std::string_view token) {
|
||||
// Derived, not random: an order can never carry a mismatched pair. Six hex
|
||||
// chars is what a human will actually type into a transfer description; at
|
||||
// this volume a collision is a curiosity, and the amount+time still
|
||||
// disambiguate at reconciliation.
|
||||
std::string out = "CC-";
|
||||
for (std::size_t i = 0; i < 6 && i < token.size(); ++i) {
|
||||
char c = token[i];
|
||||
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Live shipping rates from Sendcloud, with the zone table as the floor.
|
||||
//
|
||||
// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
|
||||
// account can book, each with a per-country price list. One configured method
|
||||
// (matched by name substring) becomes a country -> cents table, cached to disk
|
||||
// and refreshed daily by a background thread the HTTP layer starts.
|
||||
//
|
||||
// Failure posture mirrors the rest of the build pipeline: Sendcloud being
|
||||
// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
|
||||
// table (Catcrafts.Shared:Content) answers instead. A stale cached table
|
||||
// beats both, which is why the cache survives restarts.
|
||||
//
|
||||
// Like the bunq rail, this code has not run against the real API — no
|
||||
// credentials existed at build time. ParseSendcloudMethods is exercised by the
|
||||
// self-test against a canned response; the fetch around it is thin.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gShipMutex;
|
||||
ShippingConfig gShipConfig;
|
||||
ShippingTable gShipTable;
|
||||
bool gShipConfigured = false;
|
||||
|
||||
// Same alphabet as the bunq helper; duplicated rather than shared because
|
||||
// each implementation unit keeps its internals to itself.
|
||||
std::string Base64S(std::string_view in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
std::size_t i = 0;
|
||||
const auto* d = reinterpret_cast<const unsigned char*>(in.data());
|
||||
for (; i + 2 < in.size(); i += 3) {
|
||||
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||
}
|
||||
if (i + 1 == in.size()) {
|
||||
const std::uint32_t n = d[i] << 16;
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63]; out += "==";
|
||||
} else if (i + 2 == in.size()) {
|
||||
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8);
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string NowIsoS() {
|
||||
return std::format("{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
void SaveCacheLocked() {
|
||||
if (gShipConfig.cachePath.empty()) return;
|
||||
std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
|
||||
if (!out) return;
|
||||
out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
|
||||
gShipTable.method, gShipTable.fetchedAt);
|
||||
bool first = true;
|
||||
for (const auto& [cc, minor] : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
|
||||
first = false;
|
||||
}
|
||||
out << "}}\n";
|
||||
}
|
||||
|
||||
void LoadCacheLocked() {
|
||||
std::ifstream in(gShipConfig.cachePath, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
auto doc = Json::Parse(buf.str());
|
||||
if (!doc || !doc->IsObject()) return;
|
||||
ShippingTable t;
|
||||
t.method = std::string(doc->Str("method"));
|
||||
t.fetchedAt = std::string(doc->Str("fetched_at"));
|
||||
if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
|
||||
for (const auto& [k, v] : m->object) {
|
||||
if (v.type == Json::Type::Number && v.number > 0) {
|
||||
t.perCountry.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!t.perCountry.empty()) gShipTable = std::move(t);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `methodName` is a comma-separated list of name substrings, merged in order
|
||||
// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
|
||||
// market: the realistic setup is a courier inside Europe and post beyond
|
||||
// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
|
||||
// a country served by both gets the earlier method's price.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
|
||||
ShippingTable out;
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return out;
|
||||
const Json::Value* methods = doc->Find("shipping_methods");
|
||||
if (!methods || !methods->IsArray()) return out;
|
||||
|
||||
std::vector<std::string_view> filters;
|
||||
{
|
||||
std::string_view rest = methodName;
|
||||
while (!rest.empty()) {
|
||||
const std::size_t comma = rest.find(',');
|
||||
std::string_view part = rest.substr(0, comma);
|
||||
while (!part.empty() && part.front() == ' ') part.remove_prefix(1);
|
||||
while (!part.empty() && part.back() == ' ') part.remove_suffix(1);
|
||||
if (!part.empty()) filters.push_back(part);
|
||||
if (comma == std::string_view::npos) break;
|
||||
rest = rest.substr(comma + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::string_view filter : filters) {
|
||||
for (const Json::Value& method : methods->array) {
|
||||
if (!method.IsObject()) continue;
|
||||
const std::string_view name = method.Str("name");
|
||||
if (name.find(filter) == std::string_view::npos) continue;
|
||||
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += std::string(name);
|
||||
if (const Json::Value* countries = method.Find("countries");
|
||||
countries && countries->IsArray()) {
|
||||
for (const Json::Value& c : countries->array) {
|
||||
if (!c.IsObject()) continue;
|
||||
std::string cc(c.Str("iso_2"));
|
||||
if (cc.size() != 2) continue;
|
||||
// Earlier methods own their countries — a later method
|
||||
// never overrides.
|
||||
if (out.Find(cc) > 0) continue;
|
||||
// Sendcloud sends the price as a JSON number of euros.
|
||||
// Money stays integer everywhere else; this one boundary
|
||||
// rounds a decimal that is exact to the cent in a double
|
||||
// (shipping prices are far inside the safe range), and
|
||||
// llround guards the representation edge
|
||||
// (8.20*100 == 819.999...).
|
||||
const Json::Value* price = c.Find("price");
|
||||
if (!price || price->type != Json::Type::Number) continue;
|
||||
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||
if (minor <= 0) continue;
|
||||
out.perCountry.emplace_back(std::move(cc), minor);
|
||||
}
|
||||
}
|
||||
break; // first method matching THIS filter wins; next filter
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void ConfigureShipping(const ShippingConfig& config) {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
gShipConfig = config;
|
||||
gShipConfigured = !config.publicKey.empty() && !config.secretKey.empty()
|
||||
&& !config.methodName.empty();
|
||||
LoadCacheLocked();
|
||||
if (gShipConfigured) {
|
||||
std::println(std::cerr,
|
||||
"shipping: sendcloud configured (method filter '{}'){}",
|
||||
config.methodName,
|
||||
gShipTable.perCountry.empty()
|
||||
? ""
|
||||
: std::format(", cached table: {} countries from {}",
|
||||
gShipTable.perCountry.size(),
|
||||
gShipTable.fetchedAt));
|
||||
}
|
||||
}
|
||||
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld) {
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
|
||||
}
|
||||
return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
|
||||
}
|
||||
|
||||
ShippingTable CurrentShippingTable() {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
return gShipTable;
|
||||
}
|
||||
|
||||
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||
// failure the previous table (cached or zone fallback) simply stays.
|
||||
void RefreshShippingTable() {
|
||||
ShippingConfig cfg;
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (!gShipConfigured) return;
|
||||
cfg = gShipConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
Crafter::ClientHTTP1 client("panel.sendcloud.sc",
|
||||
static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = "GET";
|
||||
req.path = "/api/v2/shipping_methods";
|
||||
req.authority = "panel.sendcloud.sc";
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["authorization"] =
|
||||
"Basic " + Base64S(cfg.publicKey + ":" + cfg.secretKey);
|
||||
const Crafter::HTTPResponse res = client.Send(req);
|
||||
if (res.status != "200") {
|
||||
std::println(std::cerr, "shipping: sendcloud answered {}", res.status);
|
||||
return;
|
||||
}
|
||||
ShippingTable t = ParseSendcloudMethods(res.body, cfg.methodName);
|
||||
if (t.perCountry.empty()) {
|
||||
std::println(std::cerr,
|
||||
"shipping: no method matching '{}' with prices in the response",
|
||||
cfg.methodName);
|
||||
return;
|
||||
}
|
||||
// Sendcloud rates are the shop's ex-VAT COST. What the buyer is
|
||||
// charged must NET that cost: EU destinations are grossed up by the
|
||||
// VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
|
||||
// is covered), non-EU postage is zero-rated so cost is charged as-is.
|
||||
// Done once at table build — the cache stores consumer prices, so a
|
||||
// cache reload must not (and does not) gross up again.
|
||||
for (auto& [cc, minor] : t.perCountry) {
|
||||
if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
|
||||
}
|
||||
t.fetchedAt = NowIsoS();
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
gShipTable = std::move(t);
|
||||
SaveCacheLocked();
|
||||
}
|
||||
std::println(std::cerr, "shipping: table refreshed ({} countries, method '{}')",
|
||||
CurrentShippingTable().perCountry.size(),
|
||||
CurrentShippingTable().method);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "shipping: refresh failed: {}", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
932
server/implementations/main.cpp
Normal file
932
server/implementations/main.cpp
Normal file
|
|
@ -0,0 +1,932 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// catcrafts-server — the native product.
|
||||
//
|
||||
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||||
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||||
// as the test harness for Catcrafts.Shared.
|
||||
//
|
||||
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||||
// for every piece of markup the site emits, and it is target-neutral precisely
|
||||
// so it can be tested somewhere with a debugger, sanitizers and a normal test
|
||||
// loop instead of only inside a wasm module in a browser tab. `--selftest`
|
||||
// is how the shared code gets executed rather than merely compiled.
|
||||
//
|
||||
// crafter-build -- --product=server && ./bin/Catcrafts.Server-*/catcrafts-server --selftest
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||||
Check(actual.View() == expected, what, actual.View());
|
||||
}
|
||||
|
||||
void RunSelfTest() {
|
||||
using namespace Catcrafts::Html;
|
||||
|
||||
// ── Escape ────────────────────────────────────────────────────────
|
||||
CheckEq(Escape("plain"), "plain", "escape: passthrough");
|
||||
CheckEq(Escape("a<b"), "a<b", "escape: lt");
|
||||
CheckEq(Escape("a>b"), "a>b", "escape: gt");
|
||||
CheckEq(Escape("a&b"), "a&b", "escape: amp");
|
||||
CheckEq(Escape("say \"hi\""), "say "hi"", "escape: dquote");
|
||||
CheckEq(Escape("it's"), "it's", "escape: squote");
|
||||
// Ampersand must be escaped first or the other replacements get
|
||||
// double-encoded; a single pass makes that ordering bug impossible.
|
||||
CheckEq(Escape("<"), "&lt;", "escape: no double-encode");
|
||||
CheckEq(Escape("<script>alert(1)</script>"),
|
||||
"<script>alert(1)</script>", "escape: script tag");
|
||||
// Non-ASCII passes through untouched — the output is UTF-8, and
|
||||
// entity-encoding it would just bloat the page.
|
||||
CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough");
|
||||
CheckEq(Escape(""), "", "escape: empty");
|
||||
|
||||
// ── Num ───────────────────────────────────────────────────────────
|
||||
CheckEq(Num(0), "0", "num: zero");
|
||||
CheckEq(Num(-42), "-42", "num: negative");
|
||||
CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision");
|
||||
|
||||
// ── Attr ──────────────────────────────────────────────────────────
|
||||
CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic");
|
||||
CheckEq(Attr("data-x", "a\"b"), " data-x=\"a"b\"", "attr: value escaped");
|
||||
CheckEq(Attr("class", ""), "", "attr: empty value omits attribute");
|
||||
// An invalid name is a programming error, not user data. Emitting
|
||||
// nothing is safer than emitting mangled markup.
|
||||
CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected");
|
||||
CheckEq(Attr("x><script", "y"), "", "attr: name cannot break out");
|
||||
|
||||
// ── Url ───────────────────────────────────────────────────────────
|
||||
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
|
||||
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
|
||||
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
|
||||
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
|
||||
// Escaping alone would NOT make these safe: they contain no character
|
||||
// that needs escaping, so only a scheme allowlist stops them.
|
||||
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
|
||||
CheckEq(Url("href", "JaVaScRiPt:alert(1)"), " href=\"#\"", "url: case-insensitive");
|
||||
CheckEq(Url("href", "data:text/html,<script>"), " href=\"#\"", "url: data: neutralised");
|
||||
// Browsers strip control characters before resolving the scheme, so a
|
||||
// naive prefix check would pass this straight through.
|
||||
CheckEq(Url("href", "java\tscript:alert(1)"), " href=\"#\"", "url: embedded tab");
|
||||
CheckEq(Url("href", " javascript:alert(1)"), " href=\"#\"", "url: leading space");
|
||||
CheckEq(Url("href", "//evil.example/x"), " href=\"#\"", "url: protocol-relative blocked");
|
||||
CheckEq(Url("href", "vbscript:x"), " href=\"#\"", "url: vbscript neutralised");
|
||||
|
||||
// ── Format ────────────────────────────────────────────────────────
|
||||
// The compile-time half of this guarantee (raw std::string rejected) is
|
||||
// verified by the build itself — see the negative test in the notes.
|
||||
CheckEq(Format("<h2>{}</h2>", Escape("a<b")), "<h2>a<b</h2>", "format: escapes flow through");
|
||||
CheckEq(Format("<a{}>{}</a>", Url("href", "/x"), Escape("go")),
|
||||
"<a href=\"/x\">go</a>", "format: attr + text");
|
||||
CheckEq(Format("{}{}", Num(1), Num(2)), "12", "format: multiple args");
|
||||
CheckEq(Format("literal"), "literal", "format: no args");
|
||||
CheckEq(Format("{{literal braces}}"), "{literal braces}", "format: brace escaping");
|
||||
|
||||
// ── Join / concat ─────────────────────────────────────────────────
|
||||
const std::array<Html::SafeHtml, 3> parts{ Escape("a"), Escape("b"), Escape("c") };
|
||||
CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator");
|
||||
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
|
||||
CheckEq(Escape("a") + Escape("<"), "a<", "operator+: escapes preserved");
|
||||
}
|
||||
|
||||
void RunJsonSelfTest() {
|
||||
using namespace Catcrafts::Json;
|
||||
|
||||
auto ok = [](std::string_view text) { return Parse(text).has_value(); };
|
||||
auto bad = [](std::string_view text) { return !Parse(text).has_value(); };
|
||||
|
||||
// ── shapes ────────────────────────────────────────────────────────
|
||||
Check(ok("{}"), "json: empty object");
|
||||
Check(ok("[]"), "json: empty array");
|
||||
Check(ok(" \n\t {\"a\": 1} \n "), "json: surrounding whitespace");
|
||||
Check(ok("[1,2,3]"), "json: number array");
|
||||
Check(ok("{\"a\":{\"b\":[true,false,null]}}"), "json: nesting");
|
||||
|
||||
// ── malformed input must be rejected, not partially accepted ──────
|
||||
Check(bad("{"), "json: unterminated object");
|
||||
Check(bad("[1,]"), "json: trailing comma");
|
||||
Check(bad("{\"a\":1,}"), "json: trailing comma in object");
|
||||
Check(bad("{'a':1}"), "json: single quotes");
|
||||
Check(bad("\"unterminated"), "json: unterminated string");
|
||||
Check(bad("{\"a\" 1}"), "json: missing colon");
|
||||
Check(bad("nul"), "json: bad literal");
|
||||
Check(bad("{} garbage"), "json: trailing content rejected");
|
||||
Check(bad("[1,2] [3]"), "json: concatenated documents rejected");
|
||||
Check(bad("\"raw\nnewline\""), "json: control char in string");
|
||||
Check(bad("01"), "json: leading zero");
|
||||
Check(bad("+1"), "json: leading plus");
|
||||
Check(bad("1."), "json: trailing decimal point");
|
||||
Check(bad(".5"), "json: bare fraction");
|
||||
Check(bad("1e"), "json: empty exponent");
|
||||
Check(bad("1e+"), "json: exponent sign with no digits");
|
||||
Check(bad("-"), "json: lone minus");
|
||||
Check(bad("1e400"), "json: out of double range");
|
||||
Check(ok("0"), "json: zero");
|
||||
Check(ok("-0"), "json: negative zero");
|
||||
Check(ok("0.5"), "json: leading zero with fraction");
|
||||
Check(ok("-1.5e-3"), "json: full number grammar");
|
||||
Check(ok("1E+2"), "json: capital exponent");
|
||||
Check(bad(""), "json: empty input");
|
||||
|
||||
// ── string decoding ───────────────────────────────────────────────
|
||||
auto strOf = [](std::string_view doc) -> std::string {
|
||||
auto v = Parse(doc);
|
||||
if (!v || !v->IsObject()) return "<parse-failed>";
|
||||
return std::string(v->Str("k"));
|
||||
};
|
||||
Check(strOf(R"({"k":"a\"b"})") == "a\"b", "json: escaped quote");
|
||||
Check(strOf(R"({"k":"a\\b"})") == "a\\b", "json: escaped backslash");
|
||||
Check(strOf(R"({"k":"a\nb"})") == "a\nb", "json: newline escape");
|
||||
Check(strOf(R"({"k":"A"})") == "A", "json: \\u ascii");
|
||||
Check(strOf(R"({"k":"é"})") == "é", "json: \\u latin-1");
|
||||
Check(strOf(R"({"k":"日"})") == "日", "json: \\u BMP");
|
||||
// Astral plane arrives as a UTF-16 surrogate pair. Encoding each half
|
||||
// separately yields invalid UTF-8 — emoji in Lemmy post titles are
|
||||
// exactly this case, so it has to be combined.
|
||||
Check(strOf(R"({"k":"😺"})") == "\U0001F63A", "json: surrogate pair -> emoji");
|
||||
Check(strOf(R"({"k":"\ud83d"})") == "<EFBFBD>", "json: lone high surrogate -> U+FFFD");
|
||||
Check(strOf(R"({"k":"\ude3a"})") == "<EFBFBD>", "json: lone low surrogate -> U+FFFD");
|
||||
Check(strOf(R"({"k":"raw é ✓"})") == "raw é ✓", "json: raw utf-8 passthrough");
|
||||
|
||||
// ── accessors ─────────────────────────────────────────────────────
|
||||
auto doc = Parse(R"({"s":"x","n":42,"neg":-7,"b":true,"nul":null})");
|
||||
Check(doc.has_value(), "json: accessor doc parses");
|
||||
if (doc) {
|
||||
Check(doc->Str("s") == "x", "json: Str");
|
||||
Check(doc->Int("n") == 42, "json: Int");
|
||||
Check(doc->Int("neg") == -7, "json: Int negative");
|
||||
Check(doc->Bool("b"), "json: Bool");
|
||||
Check(doc->Str("missing", "fallback") == "fallback", "json: Str fallback");
|
||||
Check(doc->Int("missing", 99) == 99, "json: Int fallback");
|
||||
// Wrong-typed field falls back rather than reinterpreting.
|
||||
Check(doc->Int("s", 5) == 5, "json: type mismatch falls back");
|
||||
Check(doc->Find("missing") == nullptr, "json: Find absent");
|
||||
Check(doc->Find("nul") != nullptr && doc->Find("nul")->IsNull(),
|
||||
"json: present-null distinguishable from absent");
|
||||
}
|
||||
|
||||
// ── depth guard ───────────────────────────────────────────────────
|
||||
std::string deep(200, '[');
|
||||
Check(bad(deep), "json: deep nesting rejected, not stack overflow");
|
||||
}
|
||||
|
||||
void RunFormSelfTest() {
|
||||
using namespace Catcrafts::Form;
|
||||
|
||||
// ── urlencoded parsing ────────────────────────────────────────────
|
||||
auto parse = [](std::string_view b) { return ParseUrlEncoded(b); };
|
||||
|
||||
auto f = parse("email=a%40b.example&country=NL");
|
||||
Check(f.has_value(), "form: basic body parses");
|
||||
if (f) {
|
||||
Check(f->Get("email") == "a@b.example", "form: %40 decodes to @");
|
||||
Check(f->Get("country") == "NL", "form: second field");
|
||||
Check(f->Get("missing").empty(), "form: absent field is empty");
|
||||
Check(!f->Has("missing"), "form: Has() distinguishes absent");
|
||||
}
|
||||
Check(parse("a=1&&b=2")->Size() == 2, "form: empty segment tolerated");
|
||||
Check(parse("a=1&")->Size() == 1, "form: trailing & tolerated");
|
||||
Check(parse("flag")->Has("flag"), "form: valueless key present");
|
||||
Check(parse("")->Size() == 0, "form: empty body");
|
||||
Check(parse("q=hello+world")->Get("q") == "hello world", "form: + is space");
|
||||
Check(parse("q=a%2Bb")->Get("q") == "a+b", "form: %2B is a literal plus");
|
||||
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
|
||||
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
|
||||
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
|
||||
// A field name is not allowed to be empty — "=x" is malformed, not a field.
|
||||
Check(!parse("=x").has_value(), "form: empty field name rejected");
|
||||
// Oversized input must be refused outright rather than truncated: acting on
|
||||
// half a form is worse than refusing it.
|
||||
Check(!parse(std::string(kMaxBodyBytes + 1, 'a')).has_value(), "form: oversized body rejected");
|
||||
Check(!parse("a=" + std::string(kMaxFieldBytes + 1, 'x')).has_value(), "form: oversized field rejected");
|
||||
|
||||
// ── email shape ───────────────────────────────────────────────────
|
||||
Check(LooksLikeEmail("a@b.example"), "email: minimal");
|
||||
Check(LooksLikeEmail("first.last+tag@sub.domain.example"), "email: tagged, subdomain");
|
||||
Check(!LooksLikeEmail("no-at-sign"), "email: no @");
|
||||
Check(!LooksLikeEmail("@domain.example"), "email: empty local part");
|
||||
Check(!LooksLikeEmail("user@"), "email: empty domain");
|
||||
Check(!LooksLikeEmail("a@b@c.example"), "email: two @");
|
||||
Check(!LooksLikeEmail("user@dotless"), "email: dotless domain");
|
||||
Check(!LooksLikeEmail("user@.example"), "email: domain starts with dot");
|
||||
Check(!LooksLikeEmail("a b@c.example"), "email: embedded space");
|
||||
// Header-injection characters must never survive into anything that later
|
||||
// builds an email envelope.
|
||||
Check(!LooksLikeEmail("a@b.example\nBcc: x@y.example"), "email: newline rejected");
|
||||
Check(!LooksLikeEmail("a@b.example\r\nSubject: x"), "email: CRLF rejected");
|
||||
Check(!LooksLikeEmail("a,b@c.example"), "email: comma rejected");
|
||||
Check(!LooksLikeEmail("<a@b.example>"), "email: angle brackets rejected");
|
||||
Check(!LooksLikeEmail(std::string(250, 'a') + "@b.example"), "email: over 254 chars rejected");
|
||||
|
||||
// ── country code ──────────────────────────────────────────────────
|
||||
Check(LooksLikeCountryCode("NL"), "country: uppercase");
|
||||
Check(LooksLikeCountryCode("ca"), "country: lowercase accepted");
|
||||
Check(!LooksLikeCountryCode("NLD"), "country: three letters rejected");
|
||||
Check(!LooksLikeCountryCode("N"), "country: one letter rejected");
|
||||
Check(!LooksLikeCountryCode("N1"), "country: digit rejected");
|
||||
Check(!LooksLikeCountryCode(""), "country: empty rejected");
|
||||
Check(Upper("nl") == "NL", "country: normalised to upper");
|
||||
|
||||
// ── trimming ──────────────────────────────────────────────────────
|
||||
Check(Trim(" x ") == "x", "trim: spaces");
|
||||
Check(Trim("\t\r\nx\n") == "x", "trim: tabs and newlines");
|
||||
Check(Trim(" ").empty(), "trim: all whitespace");
|
||||
|
||||
// ── checkout validation ───────────────────────────────────────────
|
||||
constexpr std::string_view kGoodOrder =
|
||||
"email=a%40b.example&name=Ada&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
|
||||
|
||||
auto validate = [](std::string_view body) {
|
||||
return ValidateCheckout(*ParseUrlEncoded(body));
|
||||
};
|
||||
|
||||
auto good = validate(kGoodOrder);
|
||||
Check(good.Ok(), "checkout: valid submission accepted");
|
||||
Check(good.value.country == "NL", "checkout: country uppercased");
|
||||
Check(good.value.street == "Main St 1", "checkout: street decoded and kept");
|
||||
|
||||
Check(!validate("name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
|
||||
"checkout: missing email rejected");
|
||||
Check(!validate("email=a%40b.example&street=x&postal=1&city=y&country=NL").Ok(),
|
||||
"checkout: missing name rejected");
|
||||
Check(!validate("email=a%40b.example&name=Ada&postal=1&city=y&country=NL").Ok(),
|
||||
"checkout: missing street rejected");
|
||||
Check(!validate("email=a%40b.example&name=Ada&street=x&city=y&country=NL").Ok(),
|
||||
"checkout: missing postal rejected");
|
||||
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&country=NL").Ok(),
|
||||
"checkout: missing city rejected");
|
||||
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y").Ok(),
|
||||
"checkout: missing country rejected");
|
||||
Check(!validate("email=nonsense&name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
|
||||
"checkout: bad email rejected");
|
||||
|
||||
// Every problem is reported at once — a form that surfaces one error per
|
||||
// submission makes people resubmit to discover the rest.
|
||||
Check(validate("email=&name=&street=&postal=&city=&country=").errors.size() == 6,
|
||||
"checkout: errors accumulate");
|
||||
|
||||
// Honeypot: a filled hidden field means a bot. The message must not name
|
||||
// the trap, or it teaches the next one how to pass.
|
||||
auto pot = validate(std::string(kGoodOrder) + "&website=http%3A%2F%2Fspam");
|
||||
Check(!pot.Ok(), "checkout: honeypot rejects");
|
||||
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
|
||||
&& pot.errors[0].message.find("website") == std::string::npos,
|
||||
"checkout: honeypot failure does not name the trap");
|
||||
|
||||
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
|
||||
+ "&street=x&postal=1&city=y&country=NL").Ok(),
|
||||
"checkout: overlong name rejected");
|
||||
|
||||
// Colour and quantity: shape checks here, catalogue checks in the handler.
|
||||
Check(validate(std::string(kGoodOrder) + "&color=green&quantity=2").Ok(),
|
||||
"checkout: colour and quantity accepted");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=2").value.quantity == 2,
|
||||
"checkout: quantity parsed");
|
||||
Check(validate(kGoodOrder).value.quantity == 1, "checkout: quantity defaults to 1");
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=0").Ok(),
|
||||
"checkout: zero quantity rejected");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=9").Ok(),
|
||||
"checkout: bulk quantity welcome");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=99").Ok(),
|
||||
"checkout: the technical ceiling itself is fine");
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=100").Ok(),
|
||||
"checkout: past the technical ceiling rejected");
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
|
||||
"checkout: non-numeric quantity rejected");
|
||||
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
|
||||
"checkout: oversized colour rejected");
|
||||
|
||||
// A rejected field must still come back, or the visitor has to retype the
|
||||
// one thing they got wrong — the fastest way to lose a submission.
|
||||
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
|
||||
Check(!rejected.Ok(), "checkout: invalid pair rejected");
|
||||
Check(rejected.value.email == "notanemail", "checkout: invalid email echoed back");
|
||||
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
|
||||
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
|
||||
}
|
||||
|
||||
void RunMoneySelfTest() {
|
||||
using namespace Catcrafts::Money;
|
||||
|
||||
// ── formatting ────────────────────────────────────────────────────
|
||||
Check(FormatMinor(58000) == "580.00", "money: wire format");
|
||||
Check(FormatMinor(47934) == "479.34", "money: wire format with cents");
|
||||
Check(FormatMinor(5) == "0.05", "money: sub-unit");
|
||||
Check(FormatMinor(0) == "0.00", "money: zero");
|
||||
Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare");
|
||||
Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present");
|
||||
|
||||
// ── VAT arithmetic ────────────────────────────────────────────────
|
||||
// €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up.
|
||||
Check(NetFromGross(58000) == 47934, "vat: net from €580 gross");
|
||||
// The derived pair must reconstruct plausibly: net + vat == gross.
|
||||
Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact");
|
||||
Check(NetFromGross(0) == 0, "vat: zero");
|
||||
Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly");
|
||||
// The gross-up direction, used to charge carrier costs without eating
|
||||
// the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips.
|
||||
Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net");
|
||||
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
|
||||
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
|
||||
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
|
||||
|
||||
// ── zones and membership ──────────────────────────────────────────
|
||||
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
|
||||
Check(!IsEuCountry("GB"), "eu: UK left");
|
||||
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
|
||||
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
|
||||
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
|
||||
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
|
||||
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
|
||||
Check(ZoneFor("CA") == Zone::World, "zone: world");
|
||||
|
||||
// ── order totals ──────────────────────────────────────────────────
|
||||
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||||
Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500,
|
||||
"totals: NL");
|
||||
Check(nl.vatIncluded, "totals: NL includes VAT");
|
||||
Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping");
|
||||
|
||||
auto de = ComputeTotals(58000, 1, 2500, "DE");
|
||||
Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500,
|
||||
"totals: EU");
|
||||
|
||||
// Export: net goods, world shipping, no VAT.
|
||||
auto ca = ComputeTotals(58000, 1, 5500, "CA");
|
||||
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
|
||||
"totals: export");
|
||||
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
|
||||
|
||||
// Quantity: the export net is derived from the LINE total, not per unit —
|
||||
// per-unit rounding times qty would differ by a cent here, and the JS
|
||||
// preview mirrors this exact formula.
|
||||
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
|
||||
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(ca2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||||
|
||||
// ── the compiled-in catalogue ─────────────────────────────────────
|
||||
// Content is code now; these assertions are the contract the shop pages
|
||||
// rely on, checked against the actual shipped data.
|
||||
{
|
||||
const auto& products = Content::Products();
|
||||
Check(products.size() == 1, "content: one product");
|
||||
if (products.size() == 1) {
|
||||
const Product& pr = products[0];
|
||||
Check(pr.slug == "fp6-pmos", "content: product slug");
|
||||
// Coming-soon is the pre-launch state; launch flips it to
|
||||
// "available" and this check keeps passing either way.
|
||||
Check(pr.Buyable() || pr.ComingSoon(),
|
||||
"content: product is buyable or deliberately coming soon");
|
||||
Check(pr.variants.size() == 3, "content: three colours");
|
||||
// Cost-plus pricing, derived in code: supplier + €50, exactly.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
|
||||
"content: green = 513.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
|
||||
"content: black = 519.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
|
||||
"content: white = 604.88 supplier + 50 markup");
|
||||
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
||||
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
|
||||
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
||||
"content: cheapest is green");
|
||||
Check(pr.safetyNote.find("112") != std::string::npos
|
||||
&& pr.safetyNote.find("not yet verified") != std::string::npos,
|
||||
"content: emergency-calling safety warning present and honest");
|
||||
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
|
||||
"content: warranty is written, not a placeholder");
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||
Check(!Content::Demos().empty(), "content: demos present");
|
||||
}
|
||||
|
||||
// ── the Sendcloud response parser ─────────────────────────────────
|
||||
{
|
||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","countries":[
|
||||
{"iso_2":"NL","price":6.25},
|
||||
{"iso_2":"DE","price":8.20},
|
||||
{"iso_2":"CA","price":42.50},
|
||||
{"iso_2":"XX","price":0},
|
||||
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||||
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||||
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||||
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||||
"sendcloud: malformed payload yields nothing");
|
||||
|
||||
// Comma-separated merge: courier for Europe, post for the world; the
|
||||
// earlier method keeps any country both cover.
|
||||
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","countries":[
|
||||
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||||
{"name":"PostNL Parcels non-EU","countries":[
|
||||
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||||
{"iso_2":"DE","price":99.99}]}]})",
|
||||
"DPD Home, PostNL Parcels non-EU");
|
||||
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||||
"sendcloud: merged table covers both methods");
|
||||
Check(merged.Find("DE") == 1049,
|
||||
"sendcloud: earlier method wins a shared country");
|
||||
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||||
"sendcloud: merged method names recorded");
|
||||
}
|
||||
|
||||
// ── indicative conversion ─────────────────────────────────────────
|
||||
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||||
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||||
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||||
auto ca$ = CurrencyFor("CA");
|
||||
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||||
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||||
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||||
if (ca$) {
|
||||
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||||
}
|
||||
|
||||
// ── order tokens and references ───────────────────────────────────
|
||||
Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape");
|
||||
Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected");
|
||||
Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected");
|
||||
Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected");
|
||||
const std::string tok = Server::NewOrderToken();
|
||||
Check(IsOrderToken(tok), "token: generator emits valid tokens", tok);
|
||||
Check(Server::NewOrderToken() != tok, "token: not constant");
|
||||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
|
||||
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
|
||||
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
|
||||
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
|
||||
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
|
||||
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
|
||||
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
|
||||
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
|
||||
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
|
||||
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
|
||||
|
||||
// ── the Mollie payment parser ─────────────────────────────────────
|
||||
{
|
||||
const auto p1 = Server::ParseMolliePayment(R"({
|
||||
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
|
||||
"amount":{"value":"578.30","currency":"EUR"},
|
||||
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
|
||||
Check(p1.has_value(), "mollie: open payment parses");
|
||||
if (p1) {
|
||||
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
|
||||
Check(p1->status == "open", "mollie: status");
|
||||
Check(p1->amountMinor == 57830, "mollie: amount to cents");
|
||||
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
|
||||
"mollie: checkout link");
|
||||
Check(p1->method.empty(), "mollie: null method is empty");
|
||||
}
|
||||
const auto p2 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_x","status":"paid","method":"ideal",
|
||||
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
|
||||
Check(p2 && p2->status == "paid" && p2->method == "ideal",
|
||||
"mollie: paid payment carries the method");
|
||||
const auto p3 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
|
||||
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseMolliePayment("garbage").has_value(),
|
||||
"mollie: malformed payload rejected");
|
||||
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
|
||||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
// ── the invoice builder ───────────────────────────────────────────
|
||||
{
|
||||
Server::OrderRecord o;
|
||||
o.token = "0123456789abcdef0123456789abcdef";
|
||||
o.reference = "CC-TEST01";
|
||||
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
|
||||
o.invoicedAt = "2026-08-05T10:00:00Z";
|
||||
o.createdAt = "2026-08-05T09:55:00Z";
|
||||
o.paidVia = "ideal";
|
||||
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
|
||||
"Delft", "NL" };
|
||||
o.quantity = 2;
|
||||
o.unitMinor = 56330;
|
||||
o.goodsMinor = 112660;
|
||||
o.shippingMinor = 863;
|
||||
o.totalMinor = 113523;
|
||||
o.vatIncluded = true;
|
||||
|
||||
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||||
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
|
||||
"invoice: number heading");
|
||||
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
|
||||
"invoice: customer series shown separately");
|
||||
Check(eu.find("* Invoice number: 7") != std::string::npos,
|
||||
"invoice: sequence within the series");
|
||||
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
|
||||
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
|
||||
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
|
||||
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
|
||||
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
|
||||
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
|
||||
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
|
||||
"invoice: item names the colour");
|
||||
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
|
||||
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
|
||||
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.goodsMinor = 93107;
|
||||
o.shippingMinor = 2395;
|
||||
o.totalMinor = 95502;
|
||||
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||||
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
|
||||
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
|
||||
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
|
||||
}
|
||||
|
||||
// ── rates loader ──────────────────────────────────────────────────
|
||||
const Rates r = LoadRates(
|
||||
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
|
||||
Check(r.date == "2026-08-04", "rates: date");
|
||||
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
||||
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Load content/ from disk. The wasm host reads the same bytes out of the VFS
|
||||
// instead; the loaders are shared, so only the source of the bytes differs.
|
||||
// Content loader for the CLI modes (--render, --routes, --sitemap, --feed).
|
||||
//
|
||||
// Must stay in step with Server::LoadContent, which the --serve path uses. They
|
||||
// are separate because the CLI wants a value it can pass around while the server
|
||||
// keeps process-wide state — but a field added to one and forgotten in the other
|
||||
// shows up as content silently missing from exactly one code path, which is how
|
||||
// products came to be absent from --routes and --sitemap while the live server
|
||||
// served them fine.
|
||||
Views::SiteContent LoadContent(const std::filesystem::path& root) {
|
||||
Views::SiteContent c;
|
||||
c.projects = Content::Projects();
|
||||
c.products = Content::Products();
|
||||
c.legal = Content::LegalPages();
|
||||
c.demos = Content::Demos();
|
||||
c.posts = LoadPosts(ReadFile(root / "posts.json"));
|
||||
c.rates = LoadRates(ReadFile(root / "rates.json"));
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const std::vector<std::string_view> args(argv + 1, argv + argc);
|
||||
const auto has = [&](std::string_view f) {
|
||||
return std::find(args.begin(), args.end(), f) != args.end();
|
||||
};
|
||||
|
||||
if (has("--selftest")) {
|
||||
RunSelfTest();
|
||||
RunJsonSelfTest();
|
||||
RunFormSelfTest();
|
||||
RunMoneySelfTest();
|
||||
if (failures == 0) {
|
||||
std::println("Catcrafts.Shared self-test: all assertions passed");
|
||||
return 0;
|
||||
}
|
||||
std::println(std::cerr, "Catcrafts.Shared self-test: {} failure(s)", failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --render <path>: emit the full server-rendered document for a route.
|
||||
//
|
||||
// This is the SSR path in miniature, and it is how the markup gets
|
||||
// inspected without a browser: same renderers, same content files, same
|
||||
// output the server will eventually put on the wire.
|
||||
if (args.size() >= 2 && args[0] == "--render") {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
const Route route = ParseRoute(args[1]);
|
||||
const Views::RenderedPage page = Views::RenderRoute(route, content);
|
||||
std::print("{}", Views::RenderDocument(
|
||||
page,
|
||||
Views::RenderNav(route.kind == RouteKind::LegacyBlog ? RouteKind::Posts : route.kind),
|
||||
Views::RenderFooter(),
|
||||
/*bootScripts=*/"", // no wasm on a plain server render
|
||||
/*cssHref=*/"/styles.css"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --sitemap / --feed: generated from the same route table and Post model
|
||||
// the pages use, so they cannot drift from what the site actually serves.
|
||||
// The checked-in sitemap.xml this replaces still listed three blog posts
|
||||
// that no longer exist.
|
||||
//
|
||||
// Html::Escape's output is valid XML text: & < > " are
|
||||
// shared with XML, and it emits an apostrophe as the numeric reference
|
||||
// ' rather than the HTML-only '. So no separate XML escaper.
|
||||
if (has("--sitemap")) {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
std::print("<?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()) {
|
||||
std::print(" <url><loc>https://catcrafts.net{}</loc></url>\n",
|
||||
Html::Escape(p).Str());
|
||||
}
|
||||
// Product URLs come from the loaded catalogue rather than a second
|
||||
// hardcoded list, so the sitemap cannot advertise a product that does
|
||||
// not exist or miss one that does.
|
||||
for (const Product& pr : content.products) {
|
||||
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
|
||||
Html::Escape(pr.slug).Str());
|
||||
}
|
||||
std::print("</urlset>\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (has("--feed")) {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
std::print("{}", Views::RenderAtomFeed(content.posts));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --routes: status + title for every route, for a quick smoke check.
|
||||
if (has("--routes")) {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
for (std::string_view p : { "/", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||||
"/order/0123456789abcdef0123456789abcdef",
|
||||
"/order/not-a-token",
|
||||
"/legal/privacy", "/legal/imprint",
|
||||
"/legal/terms", "/legal/nope",
|
||||
"/projects", "/posts", "/demos",
|
||||
"/demos/raytracer", "/demos/nope", "/demo",
|
||||
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
|
||||
const Route r = ParseRoute(p);
|
||||
const Views::RenderedPage page = Views::RenderRoute(r, content);
|
||||
std::println("{:<22} status={} bytes={:<6} title={}",
|
||||
p, page.status, page.main.Size(), page.meta.title);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --serve [port] [--content=DIR] [--webroot=DIR]
|
||||
//
|
||||
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
|
||||
// Catcrafts.Server-Http.cpp for why not HTTP/3.
|
||||
//
|
||||
// Both directories are options rather than fixed paths because the
|
||||
// development layout and the deployed layout differ: in the repo the
|
||||
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
|
||||
// while on the server the content is installed next to the binary and the
|
||||
// bundle IS the webroot Caddy serves.
|
||||
if (!args.empty() && args[0] == "--serve") {
|
||||
std::uint16_t port = 8081;
|
||||
std::filesystem::path contentDir = "content";
|
||||
std::filesystem::path webroot;
|
||||
// Default alongside the content in dev; the systemd unit points this at
|
||||
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
// Payment rail selection. Flags beat environment beats default. The
|
||||
// default is "whichever provider has a key, off otherwise" so a box
|
||||
// with no credentials serves the whole site minus checkout instead of
|
||||
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||||
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||||
// checkout; the client is kept for a possible future account sweep.
|
||||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||||
: bunqKey && *bunqKey ? "bunq"
|
||||
: "off";
|
||||
bool bunqSandbox = [] {
|
||||
const char* v = std::getenv("BUNQ_SANDBOX");
|
||||
return v && std::string_view(v) == "1";
|
||||
}();
|
||||
std::filesystem::path railState;
|
||||
std::string redirectBase = [] {
|
||||
const char* v = std::getenv("ORDER_REDIRECT_BASE");
|
||||
return v && *v ? std::string(v) : std::string("https://catcrafts.net");
|
||||
}();
|
||||
|
||||
for (std::size_t i = 1; i < args.size(); ++i) {
|
||||
const std::string_view a = args[i];
|
||||
if (a.starts_with("--content=")) {
|
||||
contentDir = a.substr(10);
|
||||
} else if (a.starts_with("--webroot=")) {
|
||||
webroot = a.substr(10);
|
||||
} else if (a.starts_with("--orders=")) {
|
||||
ordersPath = a.substr(9);
|
||||
} else if (a.starts_with("--rail=")) {
|
||||
railMode = a.substr(7);
|
||||
} else if (a.starts_with("--bunq=")) {
|
||||
railMode = a.substr(7); // legacy alias for --rail=
|
||||
} else if (a.starts_with("--rail-state=")) {
|
||||
railState = a.substr(13);
|
||||
} else if (a.starts_with("--bunq-state=")) {
|
||||
railState = a.substr(13); // legacy alias for --rail-state=
|
||||
} else if (a.starts_with("--redirect-base=")) {
|
||||
redirectBase = a.substr(16);
|
||||
} else {
|
||||
std::uint32_t parsed = 0;
|
||||
if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{}
|
||||
&& parsed > 0 && parsed <= 65535) {
|
||||
port = static_cast<std::uint16_t>(parsed);
|
||||
} else {
|
||||
std::println(std::cerr, "--serve: unrecognised argument '{}'", a);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bundle's index.html supplies the <script> tags with their
|
||||
// per-build ?v= cache buster, which is why they are read rather than
|
||||
// hardcoded — a hardcoded tag would silently serve a stale module.
|
||||
//
|
||||
// A missing bundle is NOT fatal: every route except /demo renders
|
||||
// completely without the wasm, so the site degrades to plain SSR
|
||||
// instead of refusing to start.
|
||||
std::filesystem::path bundleIndex;
|
||||
std::error_code ec;
|
||||
if (!webroot.empty()) {
|
||||
bundleIndex = webroot / "index.html";
|
||||
if (!std::filesystem::exists(bundleIndex, ec)) bundleIndex.clear();
|
||||
} else if (std::filesystem::is_directory("bin", ec)) {
|
||||
for (const auto& e : std::filesystem::directory_iterator("bin", ec)) {
|
||||
if (e.is_directory() && e.path().filename().string().starts_with("Catcrafts.Net-")) {
|
||||
bundleIndex = e.path() / "index.html";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bundleIndex.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: no wasm bundle index.html found; "
|
||||
"/demo will render without the renderer");
|
||||
}
|
||||
|
||||
if (!std::filesystem::is_directory(contentDir, ec)) {
|
||||
std::println(std::cerr, "catcrafts-server: content directory '{}' not found",
|
||||
contentDir.string());
|
||||
return 2;
|
||||
}
|
||||
|
||||
Server::SetOrdersPath(ordersPath);
|
||||
Server::LoadContent(contentDir, bundleIndex);
|
||||
// Refuse to serve an empty catalogue: it almost always means the
|
||||
// content path is wrong or a JSON file is malformed, and a silently
|
||||
// empty projects page looks like a design choice rather than a bug.
|
||||
if (Server::ContentProjectCount() == 0) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: no projects loaded from '{}' — refusing to start",
|
||||
contentDir.string());
|
||||
return 2;
|
||||
}
|
||||
|
||||
// The rail. State (bunq session context, or the fake rail's paid
|
||||
// marker; Mollie needs none) defaults next to the orders file — same
|
||||
// directory, same lifecycle, same backup.
|
||||
if (railState.empty()) {
|
||||
railState = ordersPath;
|
||||
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
||||
}
|
||||
Server::RailConfig railCfg;
|
||||
railCfg.mode = railMode;
|
||||
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
||||
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
||||
: "";
|
||||
railCfg.sandbox = bunqSandbox;
|
||||
railCfg.statePath = railState;
|
||||
railCfg.redirectBase = redirectBase;
|
||||
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
||||
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: --rail={} but its API key env is not set — "
|
||||
"refusing to start with a rail that cannot work", railMode);
|
||||
return 2;
|
||||
}
|
||||
|
||||
Server::ConfigurePayments(std::move(rail), redirectBase);
|
||||
|
||||
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
||||
// keyring. Unset means unsigned dev invoices with a visible marker.
|
||||
if (const char* v = std::getenv("INVOICE_GPG_KEY"); v && *v) {
|
||||
Server::ConfigureInvoicing(v);
|
||||
}
|
||||
|
||||
// Sendcloud is optional: without credentials the compiled-in zone
|
||||
// table prices all shipping, which is exactly how dev and e2e run.
|
||||
// With credentials the refresh thread fetches per-country rates.
|
||||
Server::ShippingConfig shipCfg;
|
||||
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
||||
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
||||
if (const char* v = std::getenv("SENDCLOUD_METHOD")) shipCfg.methodName = v;
|
||||
shipCfg.cachePath = ordersPath;
|
||||
shipCfg.cachePath += ".shipping.json";
|
||||
Server::ConfigureShipping(shipCfg);
|
||||
|
||||
return Server::Serve(port);
|
||||
}
|
||||
|
||||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||||
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
||||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||||
if (!args.empty() && args[0] == "--orders") {
|
||||
std::filesystem::path file = "orders.jsonl";
|
||||
std::string markPaid, markShipped, cancel;
|
||||
for (std::size_t i = 1; i < args.size(); ++i) {
|
||||
const std::string_view a = args[i];
|
||||
auto next = [&]() -> std::string {
|
||||
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
|
||||
};
|
||||
if (a == "--mark-paid") markPaid = next();
|
||||
else if (a == "--mark-shipped") markShipped = next();
|
||||
else if (a == "--cancel") cancel = next();
|
||||
else file = a;
|
||||
}
|
||||
Server::SetOrdersPath(file);
|
||||
|
||||
auto transition = [&](const std::string& token, std::string_view status) -> int {
|
||||
auto order = Server::FindOrder(token);
|
||||
if (!order) {
|
||||
std::println(std::cerr, "no such order: {}", token);
|
||||
return 1;
|
||||
}
|
||||
const std::string now = std::format(
|
||||
"{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now()));
|
||||
if (!Server::AppendOrderStatus(token, status, now)) {
|
||||
std::println(std::cerr, "could not append to {}", file.string());
|
||||
return 1;
|
||||
}
|
||||
if (status == "paid") Server::AssignInvoiceNumber(token, now);
|
||||
std::println("{}: {} -> {}", order->reference, order->status, status);
|
||||
return 0;
|
||||
};
|
||||
if (!markPaid.empty()) return transition(markPaid, "paid");
|
||||
if (!markShipped.empty()) return transition(markShipped, "shipped");
|
||||
if (!cancel.empty()) return transition(cancel, "cancelled");
|
||||
|
||||
const auto orders = Server::ListOrders();
|
||||
std::println("orders: {}", orders.size());
|
||||
if (orders.empty()) return 0;
|
||||
std::println("");
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "via",
|
||||
"created", "token");
|
||||
for (const auto& o : orders) {
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
||||
o.buyer.country, o.color.empty() ? "-" : o.color,
|
||||
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.createdAt, o.token);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
||||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||||
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD");
|
||||
return 0;
|
||||
}
|
||||
259
server/interfaces/Catcrafts.Server.cppm
Normal file
259
server/interfaces/Catcrafts.Server.cppm
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
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 native server: server-rendered pages, order storage, and the bunq
|
||||
// payment rail.
|
||||
//
|
||||
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
||||
// needs — Crafter.Network here, OpenSSL for request signing. The division of
|
||||
// labour is that Shared decides what the markup IS and Server decides how it
|
||||
// reaches a socket, where orders live, and how money moves.
|
||||
|
||||
export module Catcrafts.Server;
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
||||
export namespace Catcrafts::Server {
|
||||
|
||||
// Parse content/*.json and lift the wasm bundle's <script> tags.
|
||||
//
|
||||
// `bundleIndexHtml` is the generated index.html from the wasm build; the
|
||||
// script tags are extracted from it verbatim because they carry a
|
||||
// ?v=<buildId> cache buster that changes every build. Pass an empty path to
|
||||
// serve no wasm at all (every page then renders as plain HTML).
|
||||
//
|
||||
// Call before Serve. Content is immutable afterwards: it is generated at
|
||||
// build time, so nothing can change it under a running process.
|
||||
void LoadContent(const std::filesystem::path& contentDir,
|
||||
const std::filesystem::path& bundleIndexHtml);
|
||||
|
||||
std::size_t ContentPostCount();
|
||||
std::size_t ContentProjectCount();
|
||||
std::size_t ContentProductCount();
|
||||
|
||||
// ── orders ────────────────────────────────────────────────────────
|
||||
//
|
||||
// An append-only JSON-lines EVENT LOG, not a database. Two event types:
|
||||
// "order" (the full record, written once) and "status" (a transition).
|
||||
// Current state is a fold over the file — later events win. Nothing is
|
||||
// ever rewritten in place, so the file is also the audit trail, and a
|
||||
// crash mid-append costs at most the line being written.
|
||||
//
|
||||
// The volume argument: this sells single-digit units per week. When that
|
||||
// is wrong by two orders of magnitude, the log imports into SQLite in one
|
||||
// sitting — the reverse migration would not be so kind.
|
||||
|
||||
struct OrderRecord {
|
||||
std::string token; // 32-hex capability; the /order/<token> URL
|
||||
std::string reference; // "CC-XXXXXX", quoted in bank transfers
|
||||
std::string product; // product slug
|
||||
std::string color; // variant slug ("green"), empty pre-variants
|
||||
std::int64_t quantity = 1;
|
||||
std::int64_t unitMinor = 0; // per-unit gross at order time — prices
|
||||
// change; the record must not
|
||||
std::string createdAt; // ISO 8601 UTC
|
||||
std::string updatedAt; // of the newest event folded in
|
||||
Form::Checkout buyer;
|
||||
std::int64_t goodsMinor = 0;
|
||||
std::int64_t shippingMinor = 0;
|
||||
std::int64_t totalMinor = 0;
|
||||
bool vatIncluded = false;
|
||||
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
||||
std::string payUrl; // the provider's hosted checkout link
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie)
|
||||
std::string paidVia; // method that settled it ("ideal", "creditcard")
|
||||
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||
};
|
||||
|
||||
void SetOrdersPath(const std::filesystem::path& path);
|
||||
bool CreateOrder(const OrderRecord& order);
|
||||
// Appends a status event. Never mutates prior lines; the fold applies it.
|
||||
// `via` records HOW a payment settled ("ideal", "creditcard") on the paid
|
||||
// transition — card money stays reversible for months, so the ledger must
|
||||
// show at a glance which orders carry that tail risk.
|
||||
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||
std::string_view isoTimestamp,
|
||||
std::string_view via = {});
|
||||
std::optional<OrderRecord> FindOrder(std::string_view token);
|
||||
std::vector<OrderRecord> ListOrders();
|
||||
|
||||
// Assigns the next invoice number in the CUSTOMER's series and appends
|
||||
// the invoice event. The scheme continues the owner's pre-shop
|
||||
// administration: customer number is a random UUID, invoices count
|
||||
// sequentially within it ("f57c6512-…-3"). Art. 226(2) permits "one or
|
||||
// more series"; per-customer is the established practice here, and the
|
||||
// ledger + payment-provider records carry the completeness proof.
|
||||
// Idempotent: an order that already has a number keeps it. Fold and
|
||||
// append happen under one lock, so two paid transitions cannot race the
|
||||
// same number.
|
||||
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
||||
std::string_view isoTimestamp);
|
||||
|
||||
// ── invoices ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A paid order's invoice: plain markdown, clearsigned with the shop's
|
||||
// GPG key so its authenticity outlives this server. The page invites the
|
||||
// buyer to download it rather than promising to host receipts forever.
|
||||
|
||||
// Pure and exported for the self-test: everything on a Dutch invoice —
|
||||
// seller identity (KVK/VAT), sequential number, dates, buyer address,
|
||||
// per-line amounts, VAT treatment for EU and export.
|
||||
std::string BuildInvoiceMarkdown(const OrderRecord& order,
|
||||
std::string_view productName,
|
||||
std::string_view colorLabel);
|
||||
|
||||
// The GPG key (uid or fingerprint) invoices are clearsigned with; empty
|
||||
// disables signing and invoices carry an UNSIGNED marker instead —
|
||||
// honest in dev, wrong in production.
|
||||
void ConfigureInvoicing(std::string gpgKeyId);
|
||||
|
||||
// Clearsign via the gpg binary (GNUPGHOME decides the keyring). nullopt
|
||||
// when signing is configured but fails — the caller must NOT serve an
|
||||
// unsigned invoice in that case.
|
||||
std::optional<std::string> ClearsignInvoice(const std::string& markdown);
|
||||
bool InvoiceSigningConfigured();
|
||||
|
||||
// 128 bits of CSPRNG entropy as 32 lowercase hex — the whole capability to
|
||||
// read one order. And its human-sized companion, derived (not random) so a
|
||||
// record can never carry a mismatched pair.
|
||||
std::string NewOrderToken();
|
||||
std::string ReferenceFromToken(std::string_view token);
|
||||
|
||||
// ── payments ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
||||
// answers "has it been paid?". Everything else — storage, rendering,
|
||||
// reconciling — is rail-agnostic, which is what will let a crypto rail
|
||||
// slot in later without reshaping orders.
|
||||
|
||||
struct PaymentLink {
|
||||
std::string payUrl;
|
||||
std::string payId;
|
||||
};
|
||||
|
||||
// What a poll learned about one payment. Pending and Dead are different
|
||||
// answers on purpose: a Mollie payment EXPIRES (unlike a bunq tab), and an
|
||||
// order whose payment can never arrive should lapse rather than sit
|
||||
// "awaiting" forever.
|
||||
enum class PayState { Pending, Paid, Dead };
|
||||
struct PaidStatus {
|
||||
PayState state = PayState::Pending;
|
||||
std::string method; // "ideal" | "creditcard" | "banktransfer" | …
|
||||
};
|
||||
|
||||
class PaymentRail {
|
||||
public:
|
||||
virtual ~PaymentRail() = default;
|
||||
// nullopt = the provider could not be reached / refused. The checkout
|
||||
// surfaces that honestly instead of creating an unpayable order.
|
||||
virtual std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) = 0;
|
||||
// nullopt = could not determine (network, auth) — retry later. Never
|
||||
// guess Dead from a transport error: only the provider saying
|
||||
// expired/canceled/failed kills an order.
|
||||
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) = 0;
|
||||
virtual std::string_view Name() const = 0;
|
||||
// How often the reconciler sweeps. The fake rail returns something
|
||||
// tiny so tests are fast; the real providers get a respectful cadence.
|
||||
virtual std::chrono::seconds PollInterval() const = 0;
|
||||
};
|
||||
|
||||
struct RailConfig {
|
||||
std::string mode; // "off" | "fake" | "mollie" | "bunq"
|
||||
std::string apiKey; // mollie: live_… or test_…; bunq: its key
|
||||
bool sandbox = false; // bunq only: public-api.sandbox.bunq.com
|
||||
std::filesystem::path statePath; // bunq: session context; fake: paid marker
|
||||
std::string redirectBase = "https://catcrafts.net";
|
||||
};
|
||||
|
||||
// nullptr for mode "off" — the shop then renders but refuses checkout.
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
||||
|
||||
// Parsed essentials of a Mollie /v2/payments object. Exported so the
|
||||
// self-test can drive the parser with canned responses — the HTTP around
|
||||
// it is thin.
|
||||
struct MolliePayment {
|
||||
std::string id;
|
||||
std::string status; // open|pending|authorized|paid|canceled|expired|failed
|
||||
std::string method; // may be empty until the payer picks one
|
||||
std::string checkoutUrl; // present while payable
|
||||
std::int64_t amountMinor = 0;
|
||||
};
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for amounts coming back from
|
||||
// the bunq API ("614.00" -> 61400). Rejects anything that is not a plain
|
||||
// non-negative decimal with at most two fraction digits — no floats touch
|
||||
// money on the way in either. Exported for the self-test.
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
||||
|
||||
// ── shipping rates ────────────────────────────────────────────────
|
||||
//
|
||||
// Live per-country rates from Sendcloud's shipping_methods API, cached to
|
||||
// a state file and refreshed daily by a background thread. The compiled-in
|
||||
// zone table (Catcrafts.Shared:Content) remains the fallback for any country the carrier table
|
||||
// does not cover — and the whole feature when no credentials exist, so
|
||||
// the shop never depends on Sendcloud being up.
|
||||
|
||||
struct ShippingConfig {
|
||||
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
||||
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
||||
std::string methodName; // substring match on the method name
|
||||
std::filesystem::path cachePath; // survives restarts
|
||||
};
|
||||
|
||||
// Country -> price in cents, EUR. Empty when nothing loaded.
|
||||
struct ShippingTable {
|
||||
std::string method; // the matched Sendcloud method name
|
||||
std::string fetchedAt; // ISO 8601, for the operator
|
||||
std::vector<std::pair<std::string, std::int64_t>> perCountry;
|
||||
|
||||
std::int64_t Find(std::string_view cc) const {
|
||||
for (const auto& [k, v] : perCountry) {
|
||||
if (k == cc) return v;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
|
||||
// the first method whose name contains `methodName` (case-sensitive).
|
||||
// Exported for the self-test — the network fetch is thin around this.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
||||
|
||||
// Install the config and start using it. Safe to skip entirely.
|
||||
void ConfigureShipping(const ShippingConfig& config);
|
||||
|
||||
// One fetch attempt; failure leaves the previous table standing. The HTTP
|
||||
// layer's background thread calls this on start and daily after.
|
||||
void RefreshShippingTable();
|
||||
|
||||
// The rate the checkout charges for `country`: the live table's price if
|
||||
// present, the product's zone fallback otherwise.
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld);
|
||||
|
||||
// A snapshot of the live table for embedding into the checkout preview —
|
||||
// the page must show the same numbers the server will charge.
|
||||
ShippingTable CurrentShippingTable();
|
||||
|
||||
// Install the rail used by Serve()'s checkout handler and reconciler.
|
||||
// Call before Serve. Passing nullptr disables checkout. (Rates travel with
|
||||
// the content — LoadContent reads rates.json.)
|
||||
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase);
|
||||
|
||||
// Bind and serve until killed. Blocks. Starts the payment reconciler
|
||||
// thread when a rail is configured.
|
||||
//
|
||||
// Plaintext HTTP/1.1 by design: Caddy terminates TLS and reverse-proxies to
|
||||
// localhost. Do not expose this port directly.
|
||||
int Serve(std::uint16_t port);
|
||||
}
|
||||
Loading…
Reference in a new issue