/* 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//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 args(argv + 1, argv + argc); const auto has = [&](std::string_view f) { return std::find(args.begin(), args.end(), f) != args.end(); }; // --render : 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("\n" "\n"); for (std::string_view p : SitemapPaths()) { std::print(" https://catcrafts.net{}\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(" https://catcrafts.net/shop/{}\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(" https://catcrafts.net/posts/{}\n", Html::Escape(po.slug).Str()); } std::print("\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/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 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 // 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. const char* mollieKey = std::getenv("MOLLIE_API_KEY"); const char* eurcChains = std::getenv("EURC_CHAINS"); std::string railMode = mollieKey && *mollieKey ? "mollie" : "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(parsed); } else { std::println(std::cerr, "--serve: unrecognised argument '{}'", a); return 2; } } } // The bundle's index.html supplies the