catcrafts.net/server/implementations/main.cpp

468 lines
23 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.
// Flags beat environment beats default, and the default for each slot
// is "the provider whose key is set, off otherwise" — so a box with no
// credentials serves the whole site minus checkout instead of refusing
// to start, and a box with only one key offers only that one method.
//
// bank MOLLIE_API_KEY iDEAL, cards, transfer
2026-08-15 00:54:05 +02:00
// crypto EURC_CHAINS self-hosted EURC, no processor, no key
//
// The crypto slot is selected by the presence of a chains FILE rather
// than a credential: the self-hosted rail has no credential, which is
// the feature.
2026-08-05 04:18:37 +02:00
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
2026-08-15 00:54:05 +02:00
const char* eurcChains = std::getenv("EURC_CHAINS");
2026-08-13 23:34:19 +02:00
std::string railMode = mollieKey && *mollieKey ? "mollie" : "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-13 23:34:19 +02:00
auto build = [&](const std::string& mode, const char* key, const char* keyName,
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.apiKey = key ? key : "";
cfg.statePath = railState;
cfg.redirectBase = redirectBase;
2026-08-15 00:54:05 +02:00
cfg.eurcChainsPath = eurcChainsPath;
cfg.eurcPoolPath = eurcPoolPath;
cfg.eurcWindowHours = eurcWindowHours;
const bool needsKey = mode == "mollie";
2026-08-13 23:34:19 +02:00
if (needsKey && cfg.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: rail '{}' selected but {} is not set — "
"refusing to start with a rail that cannot work",
mode, keyName);
return false;
}
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.
if (!out && mode != "off") {
2026-08-15 00:54:05 +02:00
// "eurc" is the one mode that constructs to nullptr for a
// reason other than a typo — its chains file or address pool
// did not load, and MakeEurcRail has already said which and
// why. Repeating "unknown rail" over the top of that would
// send the operator looking for a spelling mistake.
if (mode == "eurc") {
std::println(std::cerr,
"catcrafts-server: the eurc rail could not load its "
"chains file ({}) or address pool ({}) — see above",
eurcChainsPath.string(), eurcPoolPath.string());
} else {
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
}
2026-08-13 23:34:19 +02:00
return false;
}
return true;
};
Server::PaymentRails rails;
2026-08-15 00:54:05 +02:00
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", 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, nullptr, "", 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-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);
}
// --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;
}
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("");
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-15 00:54:05 +02:00
" [--rail=off|fake|mollie]\n"
" [--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"
"\n"
2026-08-15 00:54:05 +02:00
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail.\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (no key:\n"
" that is the point); EURC_POOL=FILE of receiving addresses,\n"
" default <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\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;
}