catcrafts.net/server/implementations/main.cpp

660 lines
33 KiB
C++
Raw Normal View History

2026-08-05 04:18:37 +02:00
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// catcrafts-server — the native product.
//
2026-08-15 00:54:05 +02:00
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML)
// and runs the shop — orders, the payment rails, the reconciler.
2026-08-05 04:18:37 +02:00
//
2026-08-15 00:54:05 +02:00
// This file is only the CLI dispatch and the serve loop; the logic lives in
// Catcrafts.ServerCore (see project.cpp). The unit tests that used to ride
// along here as `--selftest` are real crafter-build tests now, one suite per
// tests/<Name>/main.cpp:
2026-08-05 04:18:37 +02:00
//
2026-08-15 00:54:05 +02:00
// crafter-build test --product=server
2026-08-05 04:18:37 +02:00
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
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();
};
// --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,
2026-08-10 01:37:26 +02:00
Views::RenderNav(NavKindFor(route.kind)),
2026-08-05 04:18:37 +02:00
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: &amp; &lt; &gt; &quot; are
// shared with XML, and it emits an apostrophe as the numeric reference
// &#39; rather than the HTML-only &apos;. 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());
}
2026-08-10 01:37:26 +02:00
// Same rule as the served sitemap: only posts that actually have a
// page. Both must agree, because this is the copy baked into the wasm
// bundle and that one is what a crawler fetches.
for (const Post& po : content.posts) {
if (!po.HasPage()) continue;
std::print(" <url><loc>https://catcrafts.net/posts/{}</loc></url>\n",
Html::Escape(po.slug).Str());
}
2026-08-05 04:18:37 +02:00
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");
2026-08-19 17:19:51 +02:00
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
"/shop/fp6plus-pmos", "/shop/donation", "/shop/nope",
2026-08-14 02:50:58 +02:00
"/financials",
2026-08-05 04:18:37 +02:00
"/order/0123456789abcdef0123456789abcdef",
"/order/not-a-token",
"/legal/privacy", "/legal/imprint",
"/legal/terms", "/legal/nope",
2026-08-10 01:37:26 +02:00
"/projects", "/posts", "/posts/nope", "/demos",
2026-08-05 04:18:37 +02:00
"/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";
2026-08-13 23:34:19 +02:00
// Payment rail selection, one slot per payment choice the buyer gets.
2026-08-20 20:15:47 +02:00
// Flags beat environment beats default, and a slot whose configuration
// is absent is simply off — so a box with nothing configured serves the
// whole site minus checkout instead of refusing to start, and a box
// with one rail configured offers only that one method.
2026-08-13 23:34:19 +02:00
//
2026-08-20 20:15:47 +02:00
// bank TRANSFER_IBAN SEPA transfer to our own account
// crypto EURC_CHAINS self-hosted EURC, on our own addresses
2026-08-15 00:54:05 +02:00
//
2026-08-20 20:15:47 +02:00
// NEITHER slot is selected by a credential, and that is the point
// rather than an accident. Both rails are self-hosted, so each is
// selected by naming where the money lands: there is no provider to
// authenticate to, and therefore no key anyone can revoke. The shop
// ran on a hosted provider until 2026-08-20, when it closed the
// account after a risk review with no appeal and took every payment
// method with it. These two rails are the answer to that.
const char* transferIban = std::getenv("TRANSFER_IBAN");
2026-08-15 00:54:05 +02:00
const char* eurcChains = std::getenv("EURC_CHAINS");
2026-08-20 20:15:47 +02:00
std::string railMode = transferIban && *transferIban ? "transfer" : "off";
2026-08-15 00:54:05 +02:00
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
2026-08-05 04:18:37 +02:00
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);
2026-08-13 23:34:19 +02:00
} else if (a.starts_with("--crypto-rail=")) {
cryptoMode = a.substr(14);
2026-08-05 04:18:37 +02:00
} else if (a.starts_with("--rail-state=")) {
railState = a.substr(13);
} 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);
// The /financials aggregates. Same derivation convention as the rail
// marker and the shipping cache: state hangs off the orders path. The
// file is written by the owner's tooling and only read here.
2026-08-14 02:50:58 +02:00
{
Server::FinancialsConfig finCfg;
finCfg.publicPath = ordersPath;
finCfg.publicPath += ".financials.json";
Server::ConfigureFinancials(std::move(finCfg));
}
2026-08-05 04:18:37 +02:00
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;
}
2026-08-13 23:34:19 +02:00
// The rails. State (only the fake rail has any — its paid marker;
// neither real provider needs a session or a keypair) defaults next to
// the orders file: same directory, same lifecycle, same backup.
2026-08-05 04:18:37 +02:00
if (railState.empty()) {
railState = ordersPath;
2026-08-13 23:34:19 +02:00
railState += ".fake-paid";
2026-08-05 04:18:37 +02:00
}
2026-08-13 23:34:19 +02:00
// A mode whose credential is missing is a misconfiguration, not a
// reason to quietly serve a checkout that 502s at the last step. Both
// slots are checked the same way, and both name the env var they want.
2026-08-15 00:54:05 +02:00
// The self-hosted rail's two lists. Same derivation convention as every
// other piece of state — hung off the orders path, so one directory
// holds the whole shop's mutable life — and both overridable because
// the pool in particular is a file a human tops up from the wallet.
//
// EURC_CHAINS is what SELECTS this rail (see above), so it has no
// derived default: a chains file that appeared by convention rather
// than by intent would switch the crypto slot over on its own.
std::filesystem::path eurcChainsPath;
if (eurcChains && *eurcChains) eurcChainsPath = eurcChains;
std::filesystem::path eurcPoolPath;
if (const char* v = std::getenv("EURC_POOL"); v && *v) {
eurcPoolPath = v;
} else {
eurcPoolPath = ordersPath;
eurcPoolPath += ".eurc-addresses";
}
int eurcWindowHours = 24;
if (const char* v = std::getenv("EURC_WINDOW_HOURS"); v && *v) {
const std::string_view s(v);
int parsed = 0;
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 24 * 30) {
eurcWindowHours = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: EURC_WINDOW_HOURS='{}' is not a "
"sane hour count — refusing to start", s);
return 2;
}
}
2026-08-20 20:15:47 +02:00
// The bank-transfer rail's configuration. Credential-free like the EURC
// rail — there is no provider to authenticate to, only our own account
// to name — so TRANSFER_IBAN is what SELECTS it, for the same reason
// EURC_CHAINS selects the crypto slot: a value that appeared by
// convention rather than by intent must not switch a payment method on.
// The credits file is derived, though, because it is state rather than
// intent, and it hangs off the orders path like everything else.
std::filesystem::path transferCreditsPath;
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
transferCreditsPath = v;
} else {
transferCreditsPath = ordersPath;
transferCreditsPath += ".transfer-credits.jsonl";
}
int transferPollSeconds = 60;
if (const char* v = std::getenv("TRANSFER_POLL_SECONDS"); v && *v) {
const std::string_view sv(v);
int parsed = 0;
if (std::from_chars(sv.data(), sv.data() + sv.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 3600) {
transferPollSeconds = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_POLL_SECONDS='{}' is not a "
"sane second count — refusing to start", sv);
return 2;
}
}
int transferWindowHours = 14 * 24;
if (const char* v = std::getenv("TRANSFER_WINDOW_HOURS"); v && *v) {
const std::string_view s(v);
int parsed = 0;
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 24 * 90) {
transferWindowHours = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_WINDOW_HOURS='{}' is not a "
"sane hour count — refusing to start", s);
return 2;
}
}
auto build = [&](const std::string& mode,
2026-08-15 00:54:05 +02:00
std::unique_ptr<Server::PaymentRail>& out) -> bool {
2026-08-13 23:34:19 +02:00
Server::RailConfig cfg;
cfg.mode = mode;
cfg.statePath = railState;
cfg.redirectBase = redirectBase;
2026-08-15 00:54:05 +02:00
cfg.eurcChainsPath = eurcChainsPath;
cfg.eurcPoolPath = eurcPoolPath;
cfg.eurcWindowHours = eurcWindowHours;
2026-08-20 20:15:47 +02:00
if (const char* v = std::getenv("TRANSFER_IBAN"); v) cfg.transferIban = v;
if (const char* v = std::getenv("TRANSFER_BENEFICIARY"); v) {
cfg.transferBeneficiary = v;
2026-08-13 23:34:19 +02:00
}
2026-08-20 20:15:47 +02:00
if (const char* v = std::getenv("TRANSFER_BIC"); v) cfg.transferBic = v;
cfg.transferCreditsPath = transferCreditsPath;
cfg.transferPollSeconds = transferPollSeconds;
cfg.transferWindowHours = transferWindowHours;
2026-08-13 23:34:19 +02:00
out = Server::MakeRail(cfg);
// "off" is a legitimate choice and yields no rail; a mode nobody
// recognises silently would too, which is how a typo becomes a
2026-08-19 23:55:01 +02:00
// shop that quietly stops taking one kind of money. So an
// unrecognised mode is still a hard refusal — but a mode we DO
// recognise, failing on its runtime data, is not the same fault
// and must not be answered the same way (see below).
2026-08-13 23:34:19 +02:00
if (!out && mode != "off") {
2026-08-19 23:55:01 +02:00
static constexpr std::string_view kKnown[] = {
2026-08-20 20:15:47 +02:00
"eurc", "transfer", "fake", "fake-crypto"
2026-08-19 23:55:01 +02:00
};
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
if (!known) {
2026-08-15 00:54:05 +02:00
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
2026-08-19 23:55:01 +02:00
return false;
2026-08-15 00:54:05 +02:00
}
2026-08-19 23:55:01 +02:00
// A KNOWN rail that could not load its data — for "eurc",
// its chains file or address pool. MakeEurcRail has already
// said which and why, so this only names the files.
//
// This is a WARNING and not a refusal, and the reason is the
// blast radius. An exhausted address pool is a state a
// stranger can drive the shop into (every crypto checkout
// spends an address), and answering it with "the process
// refuses to boot" turns a spent pool into the entire website
// down — every page, the bank rail included — held down by
// Restart=always until a human runs an offline wallet
// ceremony. That trade is never right: this box already
// "serves the whole site minus checkout" when no credentials
// exist at all (see the slot notes above), and one rail's
// data going bad is strictly less than that.
//
// Silent degradation is the other failure to avoid, so the
// warning is loud, the listening line below reports
// crypto=off, and tools/enable-eurc.sh refuses to call an
// enable successful without the rail's own load line.
2026-08-20 23:33:50 +02:00
// Name the right files. This message used to describe EURC's
// chains file and address pool whichever rail had failed,
// which is actively misleading for the transfer rail: the
// likeliest way IT fails is a bad BUNQ_API_KEY, and being
// told to look at a chains file sends the reader away from
// the actual cause.
if (mode == "transfer") {
std::println(std::cerr,
"catcrafts-server: WARNING: the 'transfer' rail could "
"not load — see above. Usual causes: TRANSFER_IBAN or "
"TRANSFER_BENEFICIARY unset, or a BUNQ_API_KEY that "
"bunq refused. CONTINUING WITHOUT IT: bank transfer is "
"off, so checkout offers only the other rail, and the "
"rest of the site is unaffected.");
} else {
std::println(std::cerr,
"catcrafts-server: WARNING: the '{}' rail could not load "
"its chains file ({}) or address pool ({}) — see above. "
"CONTINUING WITHOUT IT: that payment choice is off and "
"the rest of the site is unaffected.",
mode, eurcChainsPath.string(), eurcPoolPath.string());
}
2026-08-19 23:55:01 +02:00
out.reset();
return true;
2026-08-13 23:34:19 +02:00
}
return true;
};
Server::PaymentRails rails;
2026-08-20 20:15:47 +02:00
if (!build(railMode, rails.bank)) return 2;
2026-08-15 00:54:05 +02:00
// The crypto slot carries no credential at all; what it needs instead
// rode in on cfg.eurc* above.
2026-08-20 20:15:47 +02:00
if (!build(cryptoMode, rails.crypto)) return 2;
2026-08-05 04:18:37 +02:00
2026-08-13 23:34:19 +02:00
Server::ConfigurePayments(std::move(rails), redirectBase);
2026-08-20 23:33:50 +02:00
// The bunq callback, which exists because the API key's IP allowlist
// forbids this host from asking bunq anything. Off unless a secret path
// is configured.
if (const char* v = std::getenv("BUNQ_WEBHOOK_PATH"); v && *v) {
Server::ConfigureBunqCallback(transferCreditsPath, v);
}
2026-08-05 04:18:37 +02:00
// 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);
}
2026-08-09 00:14:09 +02:00
// Order email: a sendmail-compatible command ("msmtp -t" on the
// server) that reads the message on stdin and takes the recipient
// from its headers. Unset means no email is sent — the order page
// and invoice download remain the buyer's receipt.
{
Server::MailConfig mailCfg;
if (const char* v = std::getenv("MAIL_COMMAND"); v && *v) mailCfg.command = v;
if (const char* v = std::getenv("MAIL_FROM"); v && *v) mailCfg.from = v;
Server::ConfigureMail(std::move(mailCfg));
}
2026-08-13 23:34:19 +02:00
// Sendcloud is the ONLY source of shipping prices: no credentials and
// no cached table means checkout refuses every order (loudly logged at
// startup). Dev and e2e get a table by writing the cache file next to
// the orders file by hand — same format the refresh writes, so no test
// hook exists for this and none can drift from production.
2026-08-05 04:18:37 +02:00
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);
}
2026-08-20 20:15:47 +02:00
// --pull-credits: read the bank account once and append anything new to the
// credits file the transfer rail settles from. Prints how many arrived.
//
// A SEPARATE ENTRY POINT ON PURPOSE, and the reason is the whole point of
// the design. A bunq API key can initiate payments — bunq has no read-only
// scope — so the project's rule is that it never lives on the public host.
// Run this on a trusted machine on a timer, ship the credits file over, and
// the server settles orders while holding no credential that can move a
// cent. Configuring BUNQ_API_KEY on the server works too and is simpler,
// but it is strictly worse and this program will say so when it starts.
//
// catcrafts-server --pull-credits [--orders FILE] [--credits FILE]
if (!args.empty() && args[0] == "--pull-credits") {
std::filesystem::path ordersPath = "orders.jsonl";
std::filesystem::path creditsPath;
std::filesystem::path statePath;
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 == "--orders") ordersPath = next();
else if (a == "--credits") creditsPath = next();
else if (a == "--state") statePath = next();
}
if (creditsPath.empty()) {
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
creditsPath = v;
} else {
creditsPath = ordersPath;
creditsPath += ".transfer-credits.jsonl";
}
}
if (statePath.empty()) {
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
statePath = v;
} else {
2026-08-20 23:33:50 +02:00
// Hung off the CREDITS file, not the orders file, because that
// is what MakeRail does — and the two MUST agree. They did not
// at first, and the cost is not cosmetic: a machine that ran
// both this command and the server would onboard twice against
// bunq, and bunq allows as few as TEN setup calls per DAY. Two
// conventions for one file is a way to spend that budget on
// nothing.
statePath = creditsPath;
2026-08-20 20:15:47 +02:00
statePath += ".bunq-context.json";
}
}
Server::BunqConfig bunq;
if (const char* v = std::getenv("BUNQ_API_KEY"); v) bunq.apiKey = v;
if (const char* v = std::getenv("TRANSFER_IBAN"); v) bunq.iban = v;
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) bunq.permittedIps = v;
if (bunq.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: BUNQ_API_KEY is not set — nothing to pull "
"with. This command reads the bank account; it never pays "
"anyone.");
return 2;
}
bunq.statePath = statePath;
std::unique_ptr<Server::CreditSource> source = Server::MakeBunqCreditSource(bunq);
if (!source) return 2;
const std::optional<int> added =
Server::PullCreditsInto(*source, creditsPath);
if (!added) {
// Distinct from "nothing new": a timer that cannot tell these
// apart will report success while the shop silently stops
// noticing payments.
std::println(std::cerr,
"catcrafts-server: could not read the account — nothing was "
"written; the credits file still holds what it did");
return 1;
}
std::println("pulled {} new credit(s) into {}", *added, creditsPath.string());
return 0;
}
2026-08-05 04:18:37 +02:00
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
2026-08-13 23:34:19 +02:00
// the escape hatch for a payment confirmed out-of-band (or a refund):
2026-08-05 04:18:37 +02:00
// --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;
}
2026-08-20 23:33:50 +02:00
// Never for a donation, exactly as the automatic paid transition
// refuses: nothing was supplied, so there is no invoice, and a
// number burned on one leaves a gap-shaped question in a
// customer's series. This manual path was missing the guard and
// minted a number for the donation CC-16083E on 2026-08-20 while
// settling a stuck test payment by hand. That number is spent and
// an append-only ledger cannot recall it, which is exactly why
// the check belongs on every path that can transition to paid,
// not only the one the reconciler takes.
if (status == "paid" && !order->donation) {
Server::AssignInvoiceNumber(token, now);
}
2026-08-05 04:18:37 +02:00
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("");
2026-08-13 23:34:19 +02:00
// `pay` is the rail the order was created on, `via` what actually
// settled it. Both, because they answer different questions: an order
// stuck awaiting needs the first (which provider's dashboard to open),
// and a paid one needs the second (whether the money can still be
// pulled back — cards can, iDEAL and crypto cannot).
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
"reference", "status", "total", "cc", "colour", "qty", "pay",
"via", "created", "token");
2026-08-05 04:18:37 +02:00
for (const auto& o : orders) {
2026-08-13 23:34:19 +02:00
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
2026-08-05 04:18:37 +02:00
o.reference, o.status, Money::FormatMinor(o.totalMinor),
o.buyer.country, o.color.empty() ? "-" : o.color,
2026-08-13 23:34:19 +02:00
o.quantity,
o.payChoice.empty() ? "-" : o.payChoice,
o.paidVia.empty() ? "-" : o.paidVia,
2026-08-05 04:18:37 +02:00
o.createdAt, o.token);
}
return 0;
}
2026-08-15 00:54:05 +02:00
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
2026-08-05 04:18:37 +02:00
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
2026-08-20 20:15:47 +02:00
" [--rail=off|fake|transfer]\n"
2026-08-15 00:54:05 +02:00
" [--crypto-rail=off|fake-crypto|eurc]\n"
2026-08-13 23:34:19 +02:00
" [--rail-state=FILE] [--redirect-base=URL]\n"
2026-08-05 04:18:37 +02:00
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
2026-08-20 20:15:47 +02:00
" --pull-credits [--orders FILE] [--credits FILE] [--state FILE]\n"
2026-08-05 04:18:37 +02:00
"\n"
2026-08-20 20:15:47 +02:00
"environment: TRANSFER_IBAN selects the bank-transfer rail (no key: that is\n"
" the point), with TRANSFER_BENEFICIARY the account-holder name\n"
" EXACTLY as the bank holds it — payers' banks name-check it —\n"
" TRANSFER_CREDITS=FILE (default <orders>.transfer-credits.jsonl)\n"
" and TRANSFER_WINDOW_HOURS (336).\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (also no\n"
" key); EURC_POOL=FILE of receiving addresses, default\n"
" <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
" BUNQ_API_KEY + BUNQ_PERMITTED_IPS, BUNQ_STATE are for\n"
" --pull-credits. That key CAN MOVE MONEY (bunq has no read-only\n"
" scope), so run --pull-credits on a trusted machine and ship the\n"
" credits file here, rather than setting it on this host.\n"
2026-08-09 00:14:09 +02:00
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
2026-08-05 04:18:37 +02:00
return 0;
}