diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 1b0a1c0..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cd ../Crafter/Crafter.Graphics && echo \"=== C++ files declaring wgpu* imports ===\"; grep -rln 'import_name\\(\"wgpu' implementations/ interfaces/; echo; echo \"=== sample: how a wgpu import + public wrapper is declared \\(canvas size + a string-taking one\\) ===\"; grep -rn -B2 -A2 -E 'import_name\\\\\\(\"wgpu\\(GetCanvasWidth|SurfaceWidth|LoadCustomShader|Init\\)\"' implementations/ interfaces/ | head -50)", - "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/**)", - "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/interfaces/**)", - "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/additional/**)", - "Bash(crafter-build --local)" - ], - "additionalDirectories": [ - "/home/jorijn/repos/Crafter/Crafter.Graphics/additional", - "/home/jorijn/repos/Crafter/Crafter.Graphics/interfaces" - ] - } -} diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 2f5c799..1e31b0f 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -27,11 +27,17 @@ jobs: # run inside this archlinux container — the runner execs them with # node. This shell step needs no node, so installing it here (before # Checkout) is enough. + # ffmpeg is for ffprobe, which tools/fetch-media.sh uses to read the + # pixel dimensions of each mirrored file. Those become the width/height + # attributes that stop the posts page reflowing as 5 MB recordings + # arrive, and tools/e2e.sh asserts they are present — so without this + # package the deploy fails at the e2e gate rather than shipping a + # janky page. pacman -Syu --noconfirm --needed \ nodejs \ clang lld libc++ \ wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \ - git curl tar rsync + git curl tar rsync zstd gzip jq openssl ffmpeg gnupg # Container runs as root; workspace may be owned by another uid. git config --global --add safe.directory '*' @@ -63,6 +69,79 @@ jobs: restore-keys: | crafter-cache-${{ runner.os }}- + - name: Fetch ECB reference rates + # Feeds the indicative national-currency line on order pages. Every + # charge is in euros; this is display only, labelled with its date — + # which is why build-time freshness is enough and no rate service is + # ever called at page-view time. Exits 0 on failure: a stale rate + # (or none — the page then shows only euros) must not fail a deploy. + run: tools/fetch-rates.sh + + - name: Fetch fediverse posts + # Build-time, not run-time: the site embeds the owner's own posts and + # links out for discussion, so there is no sync service and no runtime + # dependency on the instance being up. The script leaves the committed + # content/posts.json untouched and exits 0 on any failure, so a + # fediverse outage cannot fail a deploy. + run: tools/fetch-posts.sh + + - name: Mirror post media + # Downloads the images and screen recordings the posts carry and rewrites + # content/posts.json to point at our own copies, so nothing the browser + # loads is third-party — which is what keeps the privacy notice's + # "everything comes from catcrafts.net" true. + # + # Content-addressed and incremental: a file already on the media mount is + # never downloaded again. Writes straight into the mount so the copies + # persist across deploys — they are NOT always reproducible, because a + # source instance deleting a file leaves ours as the only one. + run: | + set -eu + if [ -d /deploy-app ]; then + mkdir -p /deploy-app/media + tools/fetch-media.sh /deploy-app/media + else + echo "WARNING: /deploy-app not mounted; mirroring to a throwaway dir." >&2 + echo "Media will be re-downloaded on every build until the mount exists." >&2 + tools/fetch-media.sh media + fi + + - name: Build and test the backend + id: srv + # The server product builds Catcrafts.Shared for the host, which is the + # only way to actually RUN the code that generates every byte of markup + # the site emits. --selftest is a gate: if escaping or the JSON reader + # regress, the deploy stops here rather than shipping broken pages. + # + # Same refuse-to-guess rule as the wasm bundle below: a variant + # directory embeds a config hash, so more than one match means the tree + # is ambiguous and picking the first would deploy an arbitrary build. + run: | + set -eux + crafter-build -- --product=server + matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort) + count=$(printf '%s\n' "$matches" | grep -c . || true) + if [ "$count" -ne 1 ]; then + echo "Expected exactly one Catcrafts.Server-* directory, found $count:" >&2 + printf '%s\n' "$matches" >&2 + exit 1 + fi + echo "srv=$matches" >> "$GITHUB_OUTPUT" + "$matches/catcrafts-server" --selftest + "$matches/catcrafts-server" --routes + + - name: Generate sitemap and Atom feed + # Both come from the same route table and Post model the pages use, so + # they cannot drift from what the site serves. Generated BEFORE the wasm + # build so cfg.files picks them up into the bundle. + env: + SRV: ${{ steps.srv.outputs.srv }} + run: | + set -eux + "$SRV/catcrafts-server" --sitemap > sitemap.xml + "$SRV/catcrafts-server" --feed > feed.xml + head -n 4 sitemap.xml + - name: Build (wasm bundle) run: crafter-build @@ -70,16 +149,78 @@ jobs: id: out run: | set -eu - dist=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | head -n1) - if [ -z "$dist" ]; then + # The directory name embeds a config hash, so glob for it. Any change + # to compile/link flags produces a NEW hash, which is why we refuse to + # guess when more than one variant is present rather than taking + # whichever the filesystem happened to list first. + matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | sort) + count=$(printf '%s\n' "$matches" | grep -c . || true) + if [ "$count" -eq 0 ]; then echo "No build output directory found under bin/" >&2 ls -la bin || true exit 1 fi + if [ "$count" -gt 1 ]; then + echo "Ambiguous build output — $count variant directories under bin/:" >&2 + printf '%s\n' "$matches" >&2 + echo "Refusing to guess which one to deploy. Clean bin/ and rebuild." >&2 + exit 1 + fi + dist=$matches echo "dist=$dist" >> "$GITHUB_OUTPUT" echo "Built bundle: $dist" ls -la "$dist" + - name: Make the static shell depth-safe + # Caddy serves this index.html directly when the backend is down, at + # whatever URL was requested — including two-segment ones like + # /demos/raytracer. Crafter.Build emits relative boot scripts and its + # runtime.js fetches variants.json/files.json/the wasm relative to the + # DOCUMENT, so at any depth the fallback loads nothing at all. The script + # roots the tags and adds , and fails loudly rather than + # silently no-opping. The SSR path handles itself; this is only the + # backend-down fallback. + env: + DIST: ${{ steps.out.outputs.dist }} + run: tools/fix-bundle-depth.sh "$DIST" + + - name: End-to-end HTTP tests + # Starts the freshly built server on a scratch port and exercises it over + # real HTTP: status codes, redirects, headers, form submission, and the + # no-JavaScript guarantee. --selftest covers the pure functions; only a + # real request can show that /nope is a 404 rather than a soft 404, that + # /projects contains its content with no ", 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 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 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(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/ and /order/ + // need no listener change when they arrive — they are just more paths + // ParseRoute already knows about. + std::unordered_map> 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 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 diff --git a/server/implementations/Catcrafts.Server-Invoice.cpp b/server/implementations/Catcrafts.Server-Invoice.cpp new file mode 100644 index 0000000..8413f49 --- /dev/null +++ b/server/implementations/Catcrafts.Server-Invoice.cpp @@ -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 +#include +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 \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 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 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 diff --git a/server/implementations/Catcrafts.Server-Mollie.cpp b/server/implementations/Catcrafts.Server-Mollie.cpp new file mode 100644 index 0000000..58fb245 --- /dev/null +++ b/server/implementations/Catcrafts.Server-Mollie.cpp @@ -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(c) < 0x20) { + out += std::format("\\u{:04x}", static_cast(c)); + } else { + out += c; + } + } + } + return out; +} + +} // namespace + +std::optional 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 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 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 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 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 Call(std::string_view method, const std::string& path, + const std::string& body) { + try { + if (!client_) { + client_ = std::make_unique( + "api.mollie.com", static_cast(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 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 MakeBunqRail(const RailConfig& config); +std::unique_ptr MakeFakeRail(const RailConfig& config); + +std::unique_ptr MakeRail(const RailConfig& config) { + if (config.mode == "fake") return MakeFakeRail(config); + if (config.mode == "mollie") return std::make_unique(config); + if (config.mode == "bunq") return MakeBunqRail(config); + return nullptr; // "off" +} + +} // namespace Catcrafts::Server diff --git a/server/implementations/Catcrafts.Server-Orders.cpp b/server/implementations/Catcrafts.Server-Orders.cpp new file mode 100644 index 0000000..c5de901 --- /dev/null +++ b/server/implementations/Catcrafts.Server-Orders.cpp @@ -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(c) < 0x20) { + out += std::format("\\u{:04x}", static_cast(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(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 FoldLocked() { + std::vector 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(c - 'A' + 'a'); + } + return out; +} + +// A random v4 UUID — the customer number, matching the pre-shop invoice +// administration (folders named by customer UUID, invoices -). +std::string NewCustomerUuid() { + std::random_device rd; + std::array w{ rd(), rd(), rd(), rd() }; + auto* b = reinterpret_cast(w.data()); + b[6] = static_cast((b[6] & 0x0f) | 0x40); // version 4 + b[8] = static_cast((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 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 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 "-". + 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 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 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(c - 'a' + 'A'); + out += c; + } + return out; +} + +} // namespace Catcrafts::Server diff --git a/server/implementations/Catcrafts.Server-Shipping.cpp b/server/implementations/Catcrafts.Server-Shipping.cpp new file mode 100644 index 0000000..f77efc5 --- /dev/null +++ b/server/implementations/Catcrafts.Server-Shipping.cpp @@ -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(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::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(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 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(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 diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp new file mode 100644 index 0000000..061a7ec --- /dev/null +++ b/server/implementations/main.cpp @@ -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("ab"), "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>", "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>"), " 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("

{}

", Escape("aa<b", "format: escapes flow through"); + CheckEq(Format("{}", Url("href", "/x"), Escape("go")), + "go", "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 parts{ Escape("a"), Escape("b"), Escape("c") }; + CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator"); + CheckEq(Join(std::span{}), "", "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 ""; + 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"})") == "�", "json: lone high surrogate -> U+FFFD"); + Check(strOf(R"({"k":"\ude3a"})") == "�", "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(""), "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 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 : 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("\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()); + } + 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 : { "/", "/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(parsed); + } else { + std::println(std::cerr, "--serve: unrecognised argument '{}'", a); + return 2; + } + } + } + + // The bundle's index.html supplies the \n"; + +// Wraps a rendered page in a complete HTML document. +// +// `bootScripts` is the ' | grep -qE 'fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon'; then + bad "$pg script" "the price hint makes network calls" + else + ok "$pg script makes no network calls" + fi +done +body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome" +body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too" + +# The shop card: one euro number as the crawler/no-JS text, every supported +# currency pre-formatted server-side as a data attribute for the script to +# pick from. Converted amounts carry "~". CAD converts the ex-VAT price; +# SEK (an EU member's currency) converts the VAT-inclusive price. +body_has /shop 'class="price__single"' "shop card renders the single-number price" +body_has /shop 'data-cad="~CA$' "shop card carries a CAD conversion" +body_has /shop 'data-sek="~kr ' "shop card carries an SEK conversion" +body_has /shop 'data-world="€465.54"' "shop card carries the euro export fallback" +# The product page gets the same headline element, so a Canadian sees ~CA$ +# at the top there too, and the buy card states the customs position plainly. +body_has /shop/fp6-pmos 'data-cad="~CA$' "product page headline carries the conversion" +body_has /shop/fp6-pmos 'indicative only' "buy card says converted prices are indicative" +body_has /shop/fp6-pmos 'customs authority' "buy card names whose problem import charges are" +body_lacks /shop/fp6-pmos 'collected on arrival' "the vague customs phrasing is gone" +# The label must not claim the Dutch rate is an EU-wide one. +body_lacks /shop/fp6-pmos 'EU VAT' "price label does not call 21% an EU-wide rate" +# The renderer loads only where a demo entry declares needsWasm — the demo LIST +# is a content page and must stay free of it. +body_has /demos/raytracer "catcrafts.wasm" "/demos/raytracer loads the wasm" +body_lacks /demos " marker is what +# it now checks; if that marker stops being emitted the guard silently stops +# working, so assert it is present and that the head is not duplicated. +body_has /demos/raytracer 'name="cc-ssr"' "SSR marker present for head.js to detect" +body_has /demos/raytracer 'Real-time ray tracer' "demo page keeps its route-specific title" +for probe in 'rel="stylesheet"' 'rel="icon"' 'name="viewport"'; do + n=$(curl -s "$BASE/demos/raytracer" | grep -o "$probe" | wc -l) + if [ "$n" = 1 ]; then ok "demo page has exactly one $probe" + else bad "demo page $probe count" "expected 1, got $n"; fi +done + +echo "== wasm boots at depth ==" +# The bug this section exists for: /demos/raytracer is two segments deep, and +# every asset the runtime needs was referenced RELATIVE to the document — +# src="runtime.js", fetch("files.json"), fetch("variants.json"), and the .wasm +# named by variants.json. So the browser asked for /demos/runtime.js, Caddy's +# try_files handed back index.html, and the module was blocked for being +# text/html. Four NS_ERROR_CORRUPTED_CONTENT failures and a blank demo. +# +# The server emits <base href="/"> on any page that boots wasm, which fixes all +# of them at once. These checks pin that, and pin the precondition that makes it +# safe: nothing else on the page may use a relative URL. +boot=$(curl -s "$BASE/demos/raytracer" | grep -c '<script src=' || true) +if [ "$boot" -eq 0 ]; then + skip "wasm boot checks" "no bundle under bin/, so no boot scripts were emitted — build the wasm product first" +else + body_has /demos/raytracer '<base href="/">' "wasm page sets <base href=\"/\">" + # Absolute script srcs regardless of the <base>, so the tags stay correct even + # if the base is ever removed. + if curl -s "$BASE/demos/raytracer" | grep -qE '<script[[:space:]][^>]*src="[^"/:]'; then + bad "boot script paths" "a script src is relative and will 404 at depth" + curl -s "$BASE/demos/raytracer" | grep -oE '<script[^>]*src="[^"]*"' >&2 + else + ok "every boot script src is absolute" + fi + # A <base> rewrites every relative URL in the document, so it is only safe + # while there are none. If a view ever emits href="x" or a bare "#frag", the + # base silently retargets it — assert the precondition rather than trusting it. + rel=$(curl -s "$BASE/demos/raytracer" \ + | grep -oE '(href|src|action)="[^"]*"' \ + | grep -cvE '="(/|https?://|mailto:)' || true) + if [ "$rel" -eq 0 ]; then + ok "wasm page has no relative URL for <base> to retarget" + else + bad "relative URLs under <base>" "$rel URL(s) would be retargeted by the base tag" + fi +fi +# The base tag belongs only where the runtime needs it. On a content page it is +# dead weight and one more thing that could retarget a future relative link. +body_lacks /posts '<base' "/posts has no base tag" +body_lacks /shop/fp6-pmos '<base' "/shop/<slug> has no base tag" + +echo "== home page actions ==" +body_has / 'Browse projects' "home links to projects" +body_has / 'Browse shop' "home links to the shop" +body_lacks / 'ray tracer' "home no longer pushes the ray tracer" + +echo "== post media ==" +# The media IS the content of these posts (screen recordings of the work), and it +# must come from our own origin: the privacy notice states that everything the +# browser loads comes from catcrafts.net, and a third-party embed would send +# every visitor's IP to whichever instance hosted the file. +if curl -s "$BASE/posts" | grep -qE '<(img|video) class="post-media__item"'; then + ok "/posts embeds its media" +else + bad "/posts media" "no embedded media found" +fi +# `poster` is in the list because a video poster is fetched on page load exactly +# like an <img> src is, so a third-party poster leaks the same visitor IP. +if curl -s "$BASE/posts" | grep -qE '(src|href|poster)="https?://[^"]*\.(mp4|webm|webp|png|jpe?g|gif)'; then + bad "/posts media origin" "media loaded from a third party" +else + ok "/posts loads no media from a third party" +fi +# Dimensions prevent layout shift as each file arrives. Needs ffprobe at fetch +# time (see the CI package list) — a build host without it produces no +# dimensions at all, which is what this catches. +if curl -s "$BASE/posts" | grep -qE '<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then + ok "images carry width/height" +else + bad "image dimensions" "no width/height on embedded images" +fi +# Videos too. This assertion exists because they silently lost theirs: ffprobe +# appends an empty CSV field for some files, so parsing `width,height` as one +# joined string yielded a height of "480x" and the guard discarded both. +if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then + ok "videos carry width/height" +else + bad "video dimensions" "no width/height on embedded videos" +fi +# A poster is the frame shown before anyone presses play, and these posts ARE +# their video. Asserting "at least one" rather than "every one": an instance that +# generated no thumbnail is a legitimate empty poster, but zero posters across +# every video means the fetch/mirror/render chain is broken. +if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*poster="/media/'; then + ok "videos carry a locally-hosted poster" +else + bad "video poster" "no video has a poster; a black box shows until play" +fi +# preload="metadata", not auto: several 5 MB recordings must not all download on +# page load. +if curl -s "$BASE/posts" | grep -q 'preload="metadata"'; then + ok "video does not preload its whole body" +else + bad "video preload" "expected preload=\"metadata\"" +fi + +echo "== headers ==" +header_has / 'x-content-type-options: *nosniff' "nosniff on pages" +header_has / 'cache-control: *public' "pages are cacheable" +header_has /nope 'x-robots-tag: *noindex' "404 is noindex" +header_has /feed.xml 'content-type: *application/atom' "feed content-type" +header_has /sitemap.xml 'content-type: *application/xml' "sitemap content-type" + +echo "== sitemap and feed content ==" +body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product" +body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page" +body_has /sitemap.xml "/demos" "sitemap lists the demos page" + +echo "== instance-agnostic copy ==" +# The account lives on one instance but posts go into communities on others, so +# no page should name a specific instance as though it were the home of the +# discussion. +# In visible text, not in href values — a post's own permalink necessarily +# contains an instance name, and that is not what this is about. Strip tags and +# check the prose. +for pg in / /posts /shop; do + if curl -s "$BASE$pg" | sed 's/<[^>]*>/ /g' | grep -qi 'ani\.social'; then + bad "$pg names an instance in visible text" "found ani.social in prose" + else + ok "$pg names no specific instance in visible text" + fi +done +body_has /posts "fediverse" "/posts refers to the fediverse generally" +# The fediverse account is not advertised at all — only individual posts are. +body_lacks / "/u/" "footer does not link a fediverse profile" +body_lacks /posts "/u/" "/posts links no account profile, only threads" + +# Every outbound thread link is a real permalink: absolute https, on some +# instance, pointing at a numeric post id. fetch-posts.sh resolves these against +# the COMMUNITY's instance rather than the author's, because that is where the +# discussion is — but a resolution failure legitimately falls back to the +# author's copy, so this checks the shape rather than naming a host. +links=$(curl -s "$BASE/posts" | grep -oE 'href="https://[a-z0-9.-]+/post/[0-9]+"' | wc -l) +if [ "$links" -gt 0 ]; then + ok "/posts links $links threads by permalink" +else + bad "post permalinks" "no https://<instance>/post/<id> link found" +fi +# Nothing should link a post by a bare id or a relative path — that would mean a +# permalink was rendered without its origin and silently resolves to catcrafts.net. +if curl -s "$BASE/posts" | grep -qE 'href="/post/[0-9]+"'; then + bad "post permalinks" "a thread link lost its instance and points at us" +else + ok "no thread link resolves to catcrafts.net" +fi +body_lacks /sitemap.xml "/blog" "sitemap does not advertise the redirect" +body_lacks /sitemap.xml "/order" "sitemap does not advertise order pages" +body_has /feed.xml "<feed xmlns=\"http://www.w3.org/2005/Atom\">" "feed is Atom" + +# Open shop or coming-soon? The pricing blob (data-cc) exists only on the real +# order form, so its presence is the probe. The checkout, order-lifecycle and +# invoice suites below only run when the shop is open; the coming-soon branch +# asserts the closed state instead. Launch day (status flip to "available" in +# Catcrafts.Shared-Content.cppm) re-arms the full suite with no e2e edit. +if curl -s "$BASE/shop/fp6-pmos" | grep -q 'data-cc='; then SHOP_OPEN=1; else SHOP_OPEN=0; fi + +echo "== the shop front ==" +# The price is rendered from the same integers the checkout charges, with the +# derived ex-VAT twin alongside — asserting both pins the arithmetic. +body_has /shop/fp6-pmos '€563.30' "product page shows the from-price (green supplier + €50)" +body_has /shop/fp6-pmos '€465.54' "product page shows the derived ex-VAT price" +body_has /shop/fp6-pmos '>from<' "product page marks the price as a from-price" +body_has /shop '€563.30' "shop card shows the from-price" +# Every colour is priced in the selector, and the form carries the exact data +# blob the preview computes from. +body_has /shop/fp6-pmos 'Black — €569.30' "colour selector prices black" +body_has /shop/fp6-pmos 'White — €654.88' "colour selector prices white" +if [ "$SHOP_OPEN" = 1 ]; then + body_has /shop/fp6-pmos 'data-cc=' "form embeds the pricing blob" + body_has /shop/fp6-pmos 'id="cc-total"' "live total element present" +else + body_has /shop/fp6-pmos 'Coming soon' "coming-soon notice on the buy panel" + body_has /shop 'coming soon' "shop card carries the coming-soon badge" + body_lacks /shop/fp6-pmos '<form' "no order form while coming soon" +fi +body_has /shop/fp6-pmos 'src="/fp6-pmos.jpg"' "product page embeds the photo" +body_has /shop 'src="/fp6-pmos.jpg"' "shop card embeds the thumbnail" +# The image file itself is Caddy's to serve (static asset), so its presence is +# asserted against the repo, not this server. +if [ -f images/fp6-pmos.jpg ]; then + ok "product photo exists in the repo" +else + bad "product photo" "images/fp6-pmos.jpg missing" +fi +body_has /shop/fp6-pmos 'not yet verified' "emergency-calling caveat is on the page" +body_lacks /shop 'reservation' "no reservation copy survives on /shop" +body_lacks /shop/fp6-pmos 'Reserve one' "no reservation form survives" + +GOOD='email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl' + +if [ "$SHOP_OPEN" = 1 ]; then + +echo "== checkout ==" + +# A valid submission answers 303 straight to the PAYMENT page — no interim +# stop. The fake rail's payUrl is the order page itself, so the token is +# still extractable from the Location and the browser flow works in dev. +LOC=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d "$GOOD" "$BASE/shop/fp6-pmos") +TOKEN=$(printf '%s' "$LOC" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true) +if [ -n "$TOKEN" ]; then + ok "POST checkout -> 303 straight to payment" +else + bad "checkout redirect" "Location was: $LOC" +fi +if grep -q '"country":"NL"' "$ORDERS" && grep -q '"total_minor":57830' "$ORDERS"; then + ok "order stored: NL total is €578.30 (green €563.30 + €15 shipping)" +else + bad "order storage" "expected NL total_minor 57830 in $ORDERS" +fi + +# The order page: awaiting payment, pay link, reference, self-refreshing, +# never indexed, never cached. +ORDER_HTML=$(curl -s "$BASE/order/$TOKEN") +printf '%s' "$ORDER_HTML" > "$WORK/order.html" +for probe in 'awaiting payment' 'Resume payment' 'CC-' 'http-equiv="refresh"' '€578.30'; do + if grep -qF -- "$probe" "$WORK/order.html"; then + ok "order page has $probe" + else + bad "order page" "missing: $probe" + fi +done +header_has "/order/$TOKEN" 'x-robots-tag: *noindex' "order page is noindex" +header_has "/order/$TOKEN" 'cache-control: *no-store' "order page is never cached" + +# Unknown and malformed tokens are the same 404. +status /order/00000000000000000000000000000000 404 +status /order/not-a-token 404 +status /order/deadbeef 404 + +# A non-EU order: ex-VAT goods, world shipping, and the indicative national +# currency line sourced from the build-time ECB rates. +LOC_CA=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA' "$BASE/shop/fp6-pmos") +TOKEN_CA=$(printf '%s' "$LOC_CA" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true) +if [ -n "$TOKEN_CA" ]; then + CA_HTML=$(curl -s "$BASE/order/$TOKEN_CA") + # €465.54 goods (green net) + €55 world shipping = €520.54 + if printf '%s' "$CA_HTML" | grep -qF '€520.54'; then + ok "export order total is ex-VAT + world shipping" + else + bad "export order total" "€520.54 not on the page" + fi + if printf '%s' "$CA_HTML" | grep -qF 'Zero-rated export'; then + ok "export order states the VAT treatment" + else + bad "export VAT copy" "missing zero-rated export note" + fi + if printf '%s' "$CA_HTML" | grep -qE '≈ CA\$[0-9]+'; then + ok "export order shows the indicative CAD amount" + else + # Rates are optional by design; their absence must not fail the file + # check, but in this repo rates.json is committed so it must appear. + bad "indicative currency" "no ≈ CA\$ line on the CA order page" + fi + if printf '%s' "$CA_HTML" | grep -qF 'indicative'; then + ok "conversion is labelled indicative" + else + bad "indicative label" "the conversion is not labelled indicative" + fi +else + bad "CA checkout" "no token from Location: $LOC_CA" +fi + +# A two-unit white export order: unit €665, line €1330, net from the LINE +# total (not per unit) = €1082.45, plus €55 world shipping = €1137.45. +LOC_W=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \ + -d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CA&color=white&quantity=2' \ + "$BASE/shop/fp6-pmos") +TOKEN_W=$(printf '%s' "$LOC_W" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true) +if [ -n "$TOKEN_W" ]; then + W_HTML=$(curl -s "$BASE/order/$TOKEN_W") + if printf '%s' "$W_HTML" | grep -qF '€1137.45'; then + ok "white ×2 export total nets the line, not the unit" + else + bad "variant qty total" "€1137.45 not on the page" + fi + if printf '%s' "$W_HTML" | grep -qF 'Device × 2'; then + ok "order page shows the quantity" + else + bad "order quantity display" "no 'Device × 2'" + fi + if printf '%s' "$W_HTML" | grep -qF 'White'; then + ok "order page names the colour" + else + bad "order colour display" "colour label missing" + fi +else + bad "white checkout" "no token from Location: $LOC_W" +fi + +# A colour we never listed must not buy anything, whatever the form claims. +status /shop/fp6-pmos 422 POST "$GOOD&color=mauve" +status /shop/fp6-pmos 422 POST "$GOOD&quantity=100" +status /shop/fp6-pmos 422 POST "$GOOD&quantity=0" +# Quantity is a free input with a technical ceiling, not a dropdown - a +# nine-unit order is business, not fraud. +LOC_9=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \ + -d "$GOOD&quantity=9" "$BASE/shop/fp6-pmos") +if printf '%s' "$LOC_9" | grep -qE '/order/[0-9a-f]{32}$'; then + ok "a nine-unit order goes through" +else + bad "bulk order" "quantity=9 did not create an order: $LOC_9" +fi +body_has /shop/fp6-pmos 'type="number"' "quantity is a number input, not a dropdown" +body_has /shop/fp6-pmos 'max="99"' "quantity input carries the technical ceiling" + +# No invoice exists before the money does — awaiting orders answer 404. +status "/order/$TOKEN/invoice.md" 404 +status /order/00000000000000000000000000000000/invoice.md 404 + +# The payment lands: create the fake rail's paid marker, then the reconciler +# (1 s cadence in fake mode) must flip the order within a few seconds. +touch "$ORDERS.fake-paid" +# The paid state shows the confirmation notice, deliberately WITHOUT a second +# "paid" badge — so the success marker is the notice text. +i=0 +until curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; do + i=$((i + 1)) + if [ "$i" -gt 40 ]; then break; fi + sleep 0.25 +done +if curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; then + ok "order confirms after payment (arrival poll or reconciler)" +else + bad "reconciler" "order still not confirmed 10s after the marker appeared" +fi +n_badges=$(curl -s "$BASE/order/$TOKEN" | grep -c 'badge--active' || true) +if [ "$n_badges" = 0 ]; then + ok "no duplicate paid badge next to the confirmation" +else + bad "badge dedupe" "found $n_badges active badges on the paid page" +fi +if curl -s "$BASE/order/$TOKEN" | grep -q 'http-equiv="refresh"'; then + bad "paid page refresh" "a settled order page still self-refreshes" +else + ok "paid order page stops self-refreshing" +fi +if grep -q '"type":"status"' "$ORDERS" && grep -q '"status":"paid"' "$ORDERS"; then + ok "paid transition is an appended event, not a rewrite" +else + bad "order event log" "no status event found in $ORDERS" +fi +# The paid event records HOW it was paid — card money stays reversible for +# months, so the ledger must show which orders carry that tail. +if grep -q '"via":"fake"' "$ORDERS"; then + ok "paid event records the payment method" +else + bad "payment method" "no via field on the paid event" +fi + +echo "== the signed invoice ==" +# Paid orders download a clearsigned markdown invoice: sequential number, +# registered identity, amounts — and a signature that verifies offline. +curl -s -D "$WORK/inv-headers" "$BASE/order/$TOKEN/invoice.md" > "$WORK/invoice.md" +for probe in 'BEGIN PGP SIGNED MESSAGE' '# Invoice ' 'Customer number: ' \ + 'Chico Mendesring 256' 'KVK 78437059' \ + 'NL003329281B38' 'CC-' 'VAT 21% (NL)' '€578.30'; do + if grep -qF -- "$probe" "$WORK/invoice.md"; then + ok "invoice has $probe" + else + bad "invoice content" "missing: $probe" + fi +done +if grep -qi 'content-disposition: *attachment' "$WORK/inv-headers"; then + ok "invoice downloads as an attachment" +else + bad "invoice headers" "no attachment disposition" +fi +if gpg --verify "$WORK/invoice.md" >/dev/null 2>&1; then + ok "invoice signature verifies with gpg" +else + bad "invoice signature" "gpg --verify failed" +fi +# Four orders were placed before the marker (two of them by the same email); +# the arrival poll paid one instantly, the reconciler sweeps the rest on its +# 1 s cadence — wait for all four invoices before judging the numbering. +i=0 +until [ "$(grep -c '"type":"invoice"' "$ORDERS" || true)" -ge 4 ]; do + i=$((i + 1)) + if [ "$i" -gt 40 ]; then break; fi + sleep 0.25 +done + +# Per-customer series, continuing the pre-shop administration: numbers are +# <customer-uuid>-<seq>, unique overall, and orders that share an email share +# a series with distinct sequence numbers. +n_inv=$(grep -c '"type":"invoice"' "$ORDERS" || true) +n_uniq=$(grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l) +if [ "$n_inv" -gt 0 ] && [ "$n_inv" = "$n_uniq" ]; then + ok "invoice numbers are unique ($n_inv issued)" +else + bad "invoice numbering" "$n_inv events, $n_uniq unique numbers" +fi +if grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | grep -qE '"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+"$'; then + ok "invoice numbers are customer-uuid series" +else + bad "invoice format" "no <uuid v4>-<seq> shaped number in the ledger" +fi +# The GOOD email placed several paid orders in this run — all of them must sit +# in ONE customer series (same uuid), with as many distinct sequence numbers. +n_customers=$(grep -o '"customer":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l) +n_orders_series=$(grep -c '"type":"invoice"' "$ORDERS") +if [ "$n_customers" -lt "$n_orders_series" ]; then + ok "repeat customer shares one series ($n_customers customers, $n_orders_series invoices)" +else + bad "customer series" "every invoice got its own customer uuid — series not shared" +fi + +else +echo "== checkout (coming soon) ==" +# A perfectly valid order must be refused while the shop is closed: after +# validation (so the field checks below still exercise the parser) and before +# any rail or ledger is touched. +status /shop/fp6-pmos 409 POST "$GOOD" +if [ -s "$ORDERS" ]; then + bad "coming-soon ledger" "a refused order still wrote to $ORDERS" +else + ok "refused order writes nothing to the ledger" +fi +skip "checkout, order-lifecycle and invoice suites" "shop is coming-soon; they re-arm when the status flips to available" +fi + +echo "== checkout validation ==" +status /shop/fp6-pmos 422 POST 'name=Ada&street=x&postal=1&city=y&country=NL' # no email +status /shop/fp6-pmos 422 POST 'email=nonsense&'"$GOOD" # bad email (dup field keeps first) +status /shop/fp6-pmos 422 POST 'email=a%40b.example&country=NL' # missing address +status /shop/fp6-pmos 422 POST "$GOOD&website=spam" # honeypot +status /shop/nope 404 POST "$GOOD" # unknown product +status /projects 405 POST 'x=1' # not a form target + +# The re-rendered form only exists when the shop is open; while coming-soon a +# rejection answers with the coming-soon page instead. +if [ "$SHOP_OPEN" = 1 ]; then +# A rejected submission must come back with the values still in it — losing a +# filled-in form is how a sale gets abandoned. +curl -s -X POST -d 'email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD' \ + "$BASE/shop/fp6-pmos" > "$WORK/rejected.html" +for probe in 'value="bad"' 'value="NLD"' 'value="Ada"' 'value="Main 1"' 'value="Delft"'; do + if grep -qF -- "$probe" "$WORK/rejected.html"; then + ok "rejected form preserves $probe" + else + bad "rejected form field" "lost: $probe" + fi +done +if grep -qF 'field__error' "$WORK/rejected.html"; then + ok "rejected form shows a field error" +else + bad "rejected form error" "no .field__error in the response" +fi +# The honeypot message must not name the trap, or it teaches the next bot. +# Only the ERROR NOTICE is inspected: the re-rendered form legitimately +# contains the name="website" field itself — that IS the trap, re-armed. +curl -s -X POST -d "$GOOD&website=x" "$BASE/shop/fp6-pmos" > "$WORK/pot.html" +notice=$(grep -o 'notice--error">[^<]*' "$WORK/pot.html" || true) +if [ -z "$notice" ]; then + bad "honeypot rejection" "no error notice rendered" +elif printf '%s' "$notice" | grep -qiE 'honeypot|website|hidden|trap'; then + bad "honeypot disclosure" "the error notice names the trap: $notice" +else + ok "honeypot failure does not name the trap" +fi +fi + +echo "== abuse ==" +status /shop/fp6-pmos 413 POST "email=a%40b.example&name=$(head -c 20000 /dev/zero | tr '\0' 'x')&street=x&postal=1&city=y&country=NL" +if curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' \ + -d '{}' "$BASE/shop/fp6-pmos" | grep -q 415; then + ok "POST with a JSON content-type -> 415" +else + bad "content-type check" "expected 415" +fi +# HEAD must not be a 500 or a body — some crawlers use it exclusively. +status / 200 HEAD + +echo +if [ "$skipped" -gt 0 ]; then + echo "e2e: $pass passed, $fail failed, $skipped skipped" +else + echo "e2e: $pass passed, $fail failed" +fi +[ "$fail" -eq 0 ] || exit 1 diff --git a/tools/fetch-media.sh b/tools/fetch-media.sh new file mode 100755 index 0000000..61a4ec3 --- /dev/null +++ b/tools/fetch-media.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# Mirror the media referenced by content/posts.json, and rewrite the entries to +# point at our own copies. +# +# Run AFTER tools/fetch-posts.sh, which records the original URLs. +# +# WHY MIRROR rather than embed from the source: +# +# * Privacy. The privacy notice states that everything the browser loads comes +# from catcrafts.net, and it should stay true. Embedding directly would send +# every visitor's IP address to whichever instance hosts the file — an odd +# thing to do on a site selling a privacy-focused phone. +# * Durability. These posts ARE their media: the screen recording of VoLTE +# working is the content. If the source instance deletes it or disappears, +# a direct embed becomes a broken box and the post loses its point. +# * Cost. One download per file, ever, instead of one per visitor. Kinder to +# small instances than hotlinking them. +# +# Files are content-addressed (sha256 of the bytes), so a file already present is +# never downloaded again and a changed file gets a new name — which makes the +# long cache lifetime Caddy sets honest. +# +# usage: tools/fetch-media.sh [media-dir] (default: media/) +# +# On any single download failure the entry keeps its original URL and the script +# carries on, so one dead file does not cost the whole page. Exits non-zero only +# if it cannot do its job at all. + +set -eu + +MEDIA_DIR="${1:-media}" +POSTS="content/posts.json" +MAX_BYTES=$((64 * 1024 * 1024)) + +command -v jq >/dev/null 2>&1 || { echo "fetch-media: jq not found" >&2; exit 1; } +[ -f "$POSTS" ] || { echo "fetch-media: $POSTS not found — run fetch-posts.sh first" >&2; exit 1; } + +mkdir -p "$MEDIA_DIR" + +# ffprobe gives real pixel dimensions, which become width/height attributes. +# Without them the browser cannot reserve space and the text below jumps as each +# image arrives; with them the layout is stable on first paint. Optional — the +# markup degrades to no dimensions rather than failing. +HAVE_FFPROBE=0 +command -v ffprobe >/dev/null 2>&1 && HAVE_FFPROBE=1 + +MAP="$(mktemp)" +trap 'rm -f "$MAP"' EXIT +printf '[]' > "$MAP" + +downloaded=0 +reused=0 +failed=0 + +# Every distinct media URL across all posts, so a file shared by two posts is +# fetched once. Video posters are in here too: a poster left pointing at the +# source instance would leak a visitor IP on page load exactly like an embedded +# image would, and it is the frame shown before anyone presses play. +# +# Fed by a here-document rather than a pipe so the counters below survive — in +# `jq | while`, the loop runs in a subshell and every increment is discarded. +while IFS= read -r src; do + [ -n "$src" ] || continue + + ext=$(printf '%s' "$src" | sed -E 's/.*\.([A-Za-z0-9]+)$/\1/' | tr 'A-Z' 'a-z') + case "$ext" in + mp4|webm|mov|webp|png|jpg|jpeg|gif|avif) ;; + *) echo "fetch-media: skipping unexpected extension: $src" >&2; continue ;; + esac + + tmp="$(mktemp)" + # --max-filesize refuses an oversized body before writing it; the explicit + # size check afterwards covers servers that do not send Content-Length. + if ! curl -fsSL --max-time 120 --max-filesize "$MAX_BYTES" \ + -A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \ + "$src" -o "$tmp" 2>/dev/null; then + echo "fetch-media: download failed, keeping original URL: $src" >&2 + rm -f "$tmp" + failed=$((failed + 1)) + continue + fi + if [ "$(wc -c < "$tmp")" -gt "$MAX_BYTES" ]; then + echo "fetch-media: oversized, keeping original URL: $src" >&2 + rm -f "$tmp" + failed=$((failed + 1)) + continue + fi + + hash=$(sha256sum "$tmp" | cut -c1-16) + name="$hash.$ext" + dest="$MEDIA_DIR/$name" + + if [ -f "$dest" ]; then + rm -f "$tmp" + reused=$((reused + 1)) + else + mv "$tmp" "$dest" + chmod 0644 "$dest" + downloaded=$((downloaded + 1)) + fi + + # One query per dimension. Asking for both at once and splitting the CSV + # looked simpler but was wrong: for some files ffprobe appends an empty + # field, so `width,height` came back as "854x480x" and splitting on `x` gave + # a height of "480x" — which the digit guard below then threw away, silently + # costing the dimensions of exactly the videos that had the extra field. + # `nk=1` prints the bare value, so there is nothing to split. + w=0; h=0 + if [ "$HAVE_FFPROBE" = 1 ]; then + pw=$(ffprobe -v error -select_streams v:0 -show_entries stream=width \ + -of default=nw=1:nk=1 "$dest" 2>/dev/null | head -n1 || true) + ph=$(ffprobe -v error -select_streams v:0 -show_entries stream=height \ + -of default=nw=1:nk=1 "$dest" 2>/dev/null | head -n1 || true) + case "$pw" in ''|*[!0-9]*) pw=0 ;; esac + case "$ph" in ''|*[!0-9]*) ph=0 ;; esac + # Both or neither: a lone dimension is worse than none, because the + # browser derives the missing one from it and gets the aspect wrong. + if [ "$pw" -gt 0 ] && [ "$ph" -gt 0 ]; then w=$pw; h=$ph; fi + if [ "$w" = 0 ]; then + echo "fetch-media: no dimensions for $name; layout will shift on load" >&2 + fi + fi + + jq --arg src "$src" --arg path "/media/$name" \ + --argjson w "${w:-0}" --argjson h "${h:-0}" \ + '. + [{src: $src, path: $path, w: $w, h: $h}]' "$MAP" > "$MAP.new" \ + && mv "$MAP.new" "$MAP" +done <<EOF +$(jq -r '[.[].media[]? | .src, (.poster // empty)] | map(select(. != "")) | unique[]' "$POSTS") +EOF + +echo "fetch-media: $downloaded new, $reused already present, $failed failed" + +# Rewrite each media entry to the local path. An entry with no mapping (download +# failed) keeps its original src, so the page still shows something rather than +# silently dropping the post's whole point. +TMP_POSTS="$(mktemp)" +if jq --slurpfile map "$MAP" ' + ($map[0] | map({key: .src, value: .}) | from_entries) as $m + | map(.media = ((.media // []) | map( + . as $item + | ($m[$item.src] // null) as $hit + | (if $hit == null then $item + else $item + { src: $hit.path, w: $hit.w, h: $hit.h } + end) + # The poster gets its path rewritten but NOT its dimensions: w/h describe + # the video, and a poster is a differently-sized still of it. Feeding the + # poster'\''s size to the <video> element would set the wrong aspect ratio. + | if (.poster // "") == "" then . + else . + { poster: (($m[.poster].path) // .poster) } + end))) + ' "$POSTS" > "$TMP_POSTS" 2>/dev/null; then + mv "$TMP_POSTS" "$POSTS" +else + rm -f "$TMP_POSTS" + echo "fetch-media: could not rewrite $POSTS, leaving it unchanged" >&2 + exit 1 +fi + +total=$(jq '[.[].media[]? | .src, (.poster // empty) | select(. != "")] | length' "$POSTS") +local_count=$(jq '[.[].media[]? | .src, (.poster // empty) + | select(startswith("/media/"))] | length' "$POSTS") +echo "fetch-media: $local_count of $total media entries served locally ($(du -sh "$MEDIA_DIR" | cut -f1) in $MEDIA_DIR)" +if [ "$local_count" -ne "$total" ]; then + echo "fetch-media: $((total - local_count)) still point at their source — see the failures above" >&2 +fi diff --git a/tools/fetch-posts.sh b/tools/fetch-posts.sh new file mode 100755 index 0000000..6c94c33 --- /dev/null +++ b/tools/fetch-posts.sh @@ -0,0 +1,219 @@ +#!/bin/sh +# Fetch selected fediverse posts and write content/posts.json. +# +# Runs at BUILD time, not run time. The site does not crawl anything, does not +# proxy, and does not mirror comments — each card links out to the thread on +# whatever instance it lives on, which is where the discussion belongs. That +# means no sync service and no runtime dependency on any instance being up. +# +# WHICH POSTS: the Lemmy user API returns everything the account has posted, +# across every community. That is not what belongs on a site about this work, so +# the result is filtered against the community allowlist in +# content/posts-sources.json. Joining a new community does not silently publish +# to the site — it has to be added there first. +# +# The output is a flat array of exactly the fields Catcrafts.Shared:Model reads. +# Doing the transformation here rather than in C++ keeps the parser small and +# makes an upstream API change a one-file fix in shell. +# +# usage: tools/fetch-posts.sh [config-file] +# +# On any failure the existing content/posts.json is left untouched and the script +# exits 0. A build must not fail because an instance was down, and stale posts +# are strictly better than an empty page. + +set -eu + +CONFIG="${1:-content/posts-sources.json}" +OUT="content/posts.json" +LIMIT=50 +EXCERPT_CHARS=280 + +command -v jq >/dev/null 2>&1 || { echo "fetch-posts: jq not found, keeping existing $OUT" >&2; exit 0; } +[ -f "$CONFIG" ] || { echo "fetch-posts: $CONFIG not found, keeping existing $OUT" >&2; exit 0; } + +USER_NAME=$(jq -r '.username // empty' "$CONFIG") +INSTANCE=$(jq -r '.instance // empty' "$CONFIG") +[ -n "$USER_NAME" ] && [ -n "$INSTANCE" ] || { + echo "fetch-posts: $CONFIG needs .username and .instance, keeping existing $OUT" >&2; exit 0; } + +# An empty allowlist would silently publish everything, which is the opposite of +# what this file is for — treat it as a configuration error, not as "allow all". +COMMUNITY_COUNT=$(jq '.communities | length' "$CONFIG") +[ "$COMMUNITY_COUNT" -gt 0 ] || { + echo "fetch-posts: .communities is empty — refusing to publish every community" >&2 + echo "fetch-posts: keeping existing $OUT" >&2; exit 0; } + +TMP="$(mktemp)" +RAW="$(mktemp)" +trap 'rm -f "$TMP" "$RAW"' EXIT + +URL="$INSTANCE/api/v3/user?username=$USER_NAME&sort=New&limit=$LIMIT" + +# Identify ourselves: an unattributed scraper on a small instance is rude and +# more likely to get blocked. +if ! curl -fsS --max-time 25 \ + -H 'Accept: application/json' \ + -A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \ + "$URL" -o "$RAW"; then + echo "fetch-posts: request failed, keeping existing $OUT" >&2 + exit 0 +fi + +# Map PostView -> the flat shape the C++ loader reads. +# +# community : assembled as name@instance from the community's actor_id, which +# is what the allowlist matches on. A post made INTO +# linuxphones@lemmy.ca has that as its community even though the +# account lives elsewhere — which is exactly the distinction that +# matters here. +# permalink : post.ap_id, the canonical federated URL. Correct even when the +# post lives on another instance, which post.id is not. This is +# also what the "discuss on the fediverse" link uses, so the reader +# lands on the real thread rather than a local mirror of it. +# media : the post's MAIN media only — `post.url` — not images embedded in +# the body. The API distinguishes them and so should we: `url` is +# what the post is about (the screen recording of the work), while +# body images are illustrations inside the prose, usually +# screenshots of comments. Pulling both in meant a card showing +# four files where one was the point. +# poster : post.thumbnail_url, the instance-generated still of that media. +# Used as a video poster so the player shows a frame instead of a +# black box before playing. +# +# Both are ORIGINAL urls here; tools/fetch-media.sh mirrors them +# and rewrites to local paths, so nothing the browser loads is +# third-party. +# excerpt : body flattened to one line and truncated. Markdown is NOT +# rendered — the site has no markdown pipeline by design, so any +# surviving syntax would show as literal characters. Strip the +# common inline markers and let the rest be plain text. +# deleted / removed posts are dropped rather than rendered as empty cards. +if ! jq --argjson n "$EXCERPT_CHARS" \ + --slurpfile cfg "$CONFIG" ' + ($cfg[0].communities | map(ascii_downcase)) as $allow + | [ .posts[] + | select((.post.deleted // false) == false) + | select((.post.removed // false) == false) + | . as $p + | ((.community.name // "") + "@" + + ((.community.actor_id // "") | sub("^https?://"; "") | sub("/c/.*$"; ""))) as $comm + | select(($comm | ascii_downcase) as $c | $allow | index($c)) + | { + title: ($p.post.name // ""), + permalink: ($p.post.ap_id // ""), + # A link post whose target IS an image or video is a media post, not a + # link post: the file is captured in `media` and embedded, so keeping + # it here too would render the raw URL as text right above the thing + # it points at. + url: (($p.post.url // "") + | if test("\\.(?:mp4|webm|mov|webp|png|jpe?g|gif|avif)$") then "" else . end), + community: $comm, + published: ($p.post.published // ""), + excerpt: (($p.post.body // "") + | gsub("\r"; "") + | gsub("\n+"; " ") + | gsub("!?\\[(?<t>[^\\]]*)\\]\\([^)]*\\)"; "\(.t)") + | gsub("[*_`>#]"; "") + # Strip every bare URL, not just media ones. A raw link + # in a 280-character preview is noise the reader cannot + # use, and the card already links to the thread — where + # the link is clickable in its original context. + | gsub("https?://[^ )\\]]+"; "") + | gsub(" +"; " ") + | ltrimstr(" ") | rtrimstr(" ") + | if (. | length) > $n then (.[0:$n] | sub(" [^ ]*$"; "")) + "…" else . end), + media: ([ ($p.post.url // "") + | select(test("\\.(?:mp4|webm|mov|webp|png|jpe?g|gif|avif)$")) + | (if test("\\.(mp4|webm|mov)$") then "video" else "image" end) as $kind + | { src: ., + kind: $kind, + # Videos only. For an image post thumbnail_url is a + # scaled copy of the image itself, and <img> has no + # poster attribute — carrying it would mirror a + # second file to render nothing. + poster: (if $kind == "video" + then (($p.post.thumbnail_url // "") + | select(test("\\.(?:webp|png|jpe?g|gif|avif)$")) // "") + else "" end) } ]), + score: ($p.counts.score // 0), + comments: ($p.counts.comments // 0) + } + ]' "$RAW" > "$TMP" 2>/dev/null; then + echo "fetch-posts: response did not match the expected shape, keeping existing $OUT" >&2 + exit 0 +fi + +# Refuse to replace good content with an empty list. Zero matches usually means +# the allowlist and the account have drifted apart, or the API shape changed — +# either way, silently emptying the posts page on the next deploy is the wrong +# response. +COUNT="$(jq 'length' "$TMP")" +if [ "$COUNT" -eq 0 ]; then + echo "fetch-posts: no posts matched the community allowlist, keeping existing $OUT" >&2 + echo "fetch-posts: allowlist is $(jq -c '.communities' "$CONFIG")" >&2 + exit 0 +fi + +# ── point each link at the community's instance ───────────────────────── +# +# `post.ap_id` is the ActivityPub canonical id, and for a post created from this +# account it is on the account's own instance — so linking it sends readers +# there. That is the wrong destination twice over: the community lives somewhere +# else, and the account is not what the site should be advertising. +# +# The community's instance has its own federated copy of the thread at a +# different local id. `resolve_object` is how to find it: hand the instance the +# ap_id and it answers with its local view. +# +# Per post, one request, at build time. Failure is not fatal — the entry keeps +# its ap_id, which still reaches a readable copy of the thread. +resolved=0 +kept=0 +LINKED="$(mktemp)" +printf '[]' > "$LINKED" + +# shellcheck disable=SC2016 +while IFS="$(printf '\t')" read -r ap comm; do + [ -n "$ap" ] || continue + host=${comm#*@} + if [ -z "$host" ] || [ "$host" = "$comm" ]; then + kept=$((kept + 1)); continue + fi + local_id=$(curl -fsS --max-time 15 \ + -A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \ + "https://$host/api/v3/resolve_object?q=$ap" 2>/dev/null \ + | jq -r '.post.post.id // empty' 2>/dev/null || true) + case "$local_id" in + ''|*[!0-9]*) + echo "fetch-posts: could not resolve $ap on $host, keeping the ap_id" >&2 + kept=$((kept + 1)) + ;; + *) + jq --arg ap "$ap" --arg url "https://$host/post/$local_id" \ + '. + [{ap: $ap, url: $url}]' "$LINKED" > "$LINKED.new" \ + && mv "$LINKED.new" "$LINKED" + resolved=$((resolved + 1)) + ;; + esac +done <<EOF +$(jq -r '.[] | [.permalink, .community] | @tsv' "$TMP") +EOF + +REWRITTEN="$(mktemp)" +if jq --slurpfile linked "$LINKED" ' + ($linked[0] | map({key: .ap, value: .url}) | from_entries) as $m + | map(. + { permalink: ($m[.permalink] // .permalink) })' "$TMP" > "$REWRITTEN" 2>/dev/null; then + mv "$REWRITTEN" "$TMP" +else + rm -f "$REWRITTEN" + echo "fetch-posts: link rewrite failed, keeping ap_ids" >&2 +fi +rm -f "$LINKED" + +mkdir -p content +mv "$TMP" "$OUT" +trap - EXIT +rm -f "$RAW" +echo "fetch-posts: wrote $COUNT posts to $OUT (from $COMMUNITY_COUNT allowed communities)" +echo "fetch-posts: $resolved links point at the community instance, $kept fell back to the ap_id" diff --git a/tools/fetch-rates.sh b/tools/fetch-rates.sh new file mode 100755 index 0000000..d801996 --- /dev/null +++ b/tools/fetch-rates.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# Fetch the ECB euro reference rates and write content/rates.json. +# +# Feeds the indicative national-currency line on the order page ("≈ CA$920 · +# ECB reference rate 2026-08-04"). Indicative is the contract: every charge is +# in euros, the buyer's bank sets the real conversion — so build-time daily +# reference rates are exactly the right freshness, and no rate service is ever +# called at page-view time (nothing third-party runs against visitors). +# +# Values are emitted as INTEGER micro-units of target currency per euro +# (1 EUR = 1.0834 USD -> 1083400), so the C++ side never parses a decimal and +# no float ever touches a money path. +# +# Like fetch-posts.sh: exits 0 on network failure, leaving any previous +# rates.json in place — a stale indicative rate labelled with its date beats a +# failed deploy. + +set -eu + +OUT="content/rates.json" +URL="https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +if ! curl -fsSL --max-time 30 \ + -A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \ + "$URL" -o "$TMP" 2>/dev/null; then + echo "fetch-rates: ECB unreachable; keeping existing $OUT" >&2 + exit 0 +fi + +# The XML is a flat list: <Cube currency='USD' rate='1.1515'/> under one +# <Cube time='2026-08-04'>. The ECB emits single-quoted attributes today; +# normalising quotes first keeps this working if they ever switch to double. +tr "'" '"' < "$TMP" > "$TMP.n" && mv "$TMP.n" "$TMP" + +DATE=$(grep -o 'time="[0-9-]*"' "$TMP" | head -n1 | cut -d'"' -f2) +if [ -z "$DATE" ]; then + echo "fetch-rates: unexpected ECB payload; keeping existing $OUT" >&2 + exit 0 +fi + +RATES=$(grep -o 'currency="[A-Z]*" rate="[0-9.]*"' "$TMP" | awk -F'"' ' + { + cur = $2; rate = $4 + # decimal -> integer micros, without floats: split on the point and + # right-pad the fraction to exactly six digits. + n = split(rate, parts, ".") + intpart = parts[1] + frac = (n > 1) ? parts[2] : "" + frac = substr(frac "000000", 1, 6) + micro = intpart frac + # strip leading zeros (but keep at least one digit) + sub(/^0+/, "", micro); if (micro == "") micro = "0" + printf "%s\"%s\":%s", (out++ ? "," : ""), cur, micro + }') + +if [ -z "$RATES" ]; then + echo "fetch-rates: no rates parsed; keeping existing $OUT" >&2 + exit 0 +fi + +printf '{"date":"%s","micro_per_eur":{%s}}\n' "$DATE" "$RATES" > "$OUT.new" + +# Sanity: the file must parse and contain USD, or something upstream changed +# shape and the old file is the safer one. +if command -v jq >/dev/null 2>&1; then + if ! jq -e '.micro_per_eur.USD > 500000' "$OUT.new" >/dev/null 2>&1; then + echo "fetch-rates: output failed sanity check; keeping existing $OUT" >&2 + rm -f "$OUT.new" + exit 0 + fi +fi + +mv "$OUT.new" "$OUT" +count=$(grep -o ':' "$OUT" | wc -l) +echo "fetch-rates: wrote $OUT ($DATE, $((count - 1)) currencies)" diff --git a/tools/fix-bundle-depth.sh b/tools/fix-bundle-depth.sh new file mode 100755 index 0000000..7de0ea9 --- /dev/null +++ b/tools/fix-bundle-depth.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# Make the wasm bundle's index.html work when it is served at a URL deeper than +# "/", and verify it stayed that way. +# +# usage: tools/fix-bundle-depth.sh <bundle-dir> +# +# WHY THIS EXISTS +# +# Crafter.Build generates an index.html whose boot scripts are relative +# (src="runtime.js?v=…"), and runtime.js in turn does fetch("variants.json"), +# fetch("files.json"), one fetch per VFS entry, and fetches the .wasm named by +# variants.json — all relative. Relative to the DOCUMENT, not to the module. +# +# That is correct when the document is "/". It is broken for every deeper path, +# and this site has several: /demos/raytracer, /shop/<slug>, /legal/<page>. A +# document at /demos/raytracer sends the browser to /demos/runtime.js, which does +# not exist, so Caddy's `try_files {path} /index.html` returns index.html — and +# the browser refuses to execute a module served as text/html. The visible result +# is four NS_ERROR_CORRUPTED_CONTENT errors and a dead page. +# +# The SSR path solves this itself (Views::RenderDocument emits <base href="/"> on +# any page that boots wasm). This script covers the OTHER path: the static shell +# Caddy serves directly when the backend is down, where there is no SSR to help. +# +# Two changes, both idempotent: +# 1. <base href="/"> in <head>, which fixes every relative fetch runtime.js +# makes, since a bare relative fetch() resolves against the document base. +# 2. Root the boot script srcs, which <base> already handles but which is worth +# doing anyway so the tags are correct even if the <base> is ever dropped. +# +# The durable fix belongs upstream: runtime.js should resolve its own assets +# against import.meta.url rather than the document. Then no bundle would care how +# deep the page is. Until then, this. + +set -eu + +DIR="${1:-}" +[ -n "$DIR" ] || { echo "usage: tools/fix-bundle-depth.sh <bundle-dir>" >&2; exit 1; } +IDX="$DIR/index.html" +[ -f "$IDX" ] || { echo "fix-bundle-depth: $IDX not found" >&2; exit 1; } + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +# 1. Root every relative script src. Anchored on `src="` immediately followed by +# something that is not / : and : covers http:, https: and any other scheme, +# / covers both already-rooted and protocol-relative //host. +sed -E 's|(<script[^>]*[[:space:]]src=")([^"/:][^"]*")|\1/\2|g' "$IDX" > "$TMP" + +# 2. Insert <base href="/"> as the first thing in <head>, unless one is present. +# First, so it applies to everything after it — a <base> only governs the +# references that follow it. +if ! grep -qi '<base[[:space:]]' "$TMP"; then + sed -E '0,/<head>/s|<head>|<head>\n<base href="/">|' "$TMP" > "$TMP.b" \ + && mv "$TMP.b" "$TMP" +fi + +mv "$TMP" "$IDX" +trap - EXIT + +# Verify rather than assume. A silent no-op here would ship the broken page. +fail=0 +if ! grep -qi '<base href="/">' "$IDX"; then + echo "fix-bundle-depth: FAILED to insert <base> into $IDX" >&2 + fail=1 +fi +rel=$(grep -oE '<script[^>]*[[:space:]]src="[^"/:][^"]*"' "$IDX" | wc -l) +if [ "$rel" -ne 0 ]; then + echo "fix-bundle-depth: $rel script src(s) are still relative in $IDX:" >&2 + grep -oE '<script[^>]*[[:space:]]src="[^"/:][^"]*"' "$IDX" >&2 + fail=1 +fi +[ "$fail" -eq 0 ] || exit 1 + +n=$(grep -coE '<script[^>]*[[:space:]]src="/' "$IDX" || true) +echo "fix-bundle-depth: $IDX has <base href=\"/\"> and $n rooted script src(s)"