All checks were successful
Deploy / build-deploy (push) Successful in 3m11s
660 lines
33 KiB
C++
660 lines
33 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// catcrafts-server — the native product.
|
|
//
|
|
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML)
|
|
// and runs the shop — orders, the payment rails, the reconciler.
|
|
//
|
|
// 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:
|
|
//
|
|
// crafter-build test --product=server
|
|
|
|
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,
|
|
Views::RenderNav(NavKindFor(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());
|
|
}
|
|
// 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());
|
|
}
|
|
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 : { "/", "/about", "/shop", "/shop/fp6-pmos",
|
|
"/shop/fp6plus-pmos", "/shop/donation", "/shop/nope",
|
|
"/financials",
|
|
"/order/0123456789abcdef0123456789abcdef",
|
|
"/order/not-a-token",
|
|
"/legal/privacy", "/legal/imprint",
|
|
"/legal/terms", "/legal/nope",
|
|
"/projects", "/posts", "/posts/nope", "/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, one slot per payment choice the buyer gets.
|
|
// 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.
|
|
//
|
|
// bank TRANSFER_IBAN SEPA transfer to our own account
|
|
// crypto EURC_CHAINS self-hosted EURC, on our own addresses
|
|
//
|
|
// 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");
|
|
const char* eurcChains = std::getenv("EURC_CHAINS");
|
|
std::string railMode = transferIban && *transferIban ? "transfer" : "off";
|
|
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
|
|
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("--crypto-rail=")) {
|
|
cryptoMode = a.substr(14);
|
|
} 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.
|
|
{
|
|
Server::FinancialsConfig finCfg;
|
|
finCfg.publicPath = ordersPath;
|
|
finCfg.publicPath += ".financials.json";
|
|
Server::ConfigureFinancials(std::move(finCfg));
|
|
}
|
|
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 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.
|
|
if (railState.empty()) {
|
|
railState = ordersPath;
|
|
railState += ".fake-paid";
|
|
}
|
|
// 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.
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
|
Server::RailConfig cfg;
|
|
cfg.mode = mode;
|
|
cfg.statePath = railState;
|
|
cfg.redirectBase = redirectBase;
|
|
cfg.eurcChainsPath = eurcChainsPath;
|
|
cfg.eurcPoolPath = eurcPoolPath;
|
|
cfg.eurcWindowHours = eurcWindowHours;
|
|
if (const char* v = std::getenv("TRANSFER_IBAN"); v) cfg.transferIban = v;
|
|
if (const char* v = std::getenv("TRANSFER_BENEFICIARY"); v) {
|
|
cfg.transferBeneficiary = v;
|
|
}
|
|
if (const char* v = std::getenv("TRANSFER_BIC"); v) cfg.transferBic = v;
|
|
cfg.transferCreditsPath = transferCreditsPath;
|
|
cfg.transferPollSeconds = transferPollSeconds;
|
|
cfg.transferWindowHours = transferWindowHours;
|
|
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
|
|
// 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).
|
|
if (!out && mode != "off") {
|
|
static constexpr std::string_view kKnown[] = {
|
|
"eurc", "transfer", "fake", "fake-crypto"
|
|
};
|
|
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
|
|
if (!known) {
|
|
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
|
|
return false;
|
|
}
|
|
// 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.
|
|
// 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());
|
|
}
|
|
out.reset();
|
|
return true;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
Server::PaymentRails rails;
|
|
if (!build(railMode, rails.bank)) return 2;
|
|
// The crypto slot carries no credential at all; what it needs instead
|
|
// rode in on cfg.eurc* above.
|
|
if (!build(cryptoMode, rails.crypto)) return 2;
|
|
|
|
Server::ConfigurePayments(std::move(rails), redirectBase);
|
|
// 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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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.
|
|
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);
|
|
}
|
|
|
|
// --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 {
|
|
// 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;
|
|
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;
|
|
}
|
|
|
|
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
|
// the escape hatch for a payment 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;
|
|
}
|
|
// 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);
|
|
}
|
|
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("");
|
|
// `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");
|
|
for (const auto& o : orders) {
|
|
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
|
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
|
o.buyer.country, o.color.empty() ? "-" : o.color,
|
|
o.quantity,
|
|
o.payChoice.empty() ? "-" : o.payChoice,
|
|
o.paidVia.empty() ? "-" : o.paidVia,
|
|
o.createdAt, o.token);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
|
|
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
|
" [--rail=off|fake|transfer]\n"
|
|
" [--crypto-rail=off|fake-crypto|eurc]\n"
|
|
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
|
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
|
" --pull-credits [--orders FILE] [--credits FILE] [--state FILE]\n"
|
|
"\n"
|
|
"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"
|
|
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
|
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
|
return 0;
|
|
}
|