2026-08-05 04:18:37 +02:00
|
|
|
|
/*
|
|
|
|
|
|
catcrafts.net
|
|
|
|
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
|
|
|
|
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
|
|
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// catcrafts-server — the native product.
|
|
|
|
|
|
//
|
|
|
|
|
|
// 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("a<b"), "a<b", "escape: lt");
|
|
|
|
|
|
CheckEq(Escape("a>b"), "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>"),
|
|
|
|
|
|
"<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><script", "y"), "", "attr: name cannot break out");
|
|
|
|
|
|
|
|
|
|
|
|
// ── Url ───────────────────────────────────────────────────────────
|
|
|
|
|
|
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
|
|
|
|
|
|
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
|
|
|
|
|
|
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
|
|
|
|
|
|
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
|
|
|
|
|
|
// Escaping alone would NOT make these safe: they contain no character
|
|
|
|
|
|
// that needs escaping, so only a scheme allowlist stops them.
|
|
|
|
|
|
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
|
|
|
|
|
|
CheckEq(Url("href", "JaVaScRiPt:alert(1)"), " href=\"#\"", "url: case-insensitive");
|
|
|
|
|
|
CheckEq(Url("href", "data:text/html,<script>"), " 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("<h2>{}</h2>", Escape("a<b")), "<h2>a<b</h2>", "format: escapes flow through");
|
|
|
|
|
|
CheckEq(Format("<a{}>{}</a>", Url("href", "/x"), Escape("go")),
|
|
|
|
|
|
"<a href=\"/x\">go</a>", "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<Html::SafeHtml, 3> parts{ Escape("a"), Escape("b"), Escape("c") };
|
|
|
|
|
|
CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator");
|
|
|
|
|
|
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "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 "<parse-failed>";
|
|
|
|
|
|
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"})") == "<EFBFBD>", "json: lone high surrogate -> U+FFFD");
|
|
|
|
|
|
Check(strOf(R"({"k":"\ude3a"})") == "<EFBFBD>", "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("<a@b.example>"), "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");
|
2026-08-05 07:05:00 +02:00
|
|
|
|
// The product page's schema.org record must parse with our own
|
2026-08-06 23:42:25 +02:00
|
|
|
|
// JSON parser and carry one variant Product per colour, each
|
|
|
|
|
|
// with its ONE offer — built from the same integers the
|
|
|
|
|
|
// checkout charges.
|
2026-08-05 07:05:00 +02:00
|
|
|
|
{
|
|
|
|
|
|
auto pp = Views::RenderProduct(pr, Rates{});
|
|
|
|
|
|
auto ld = Json::Parse(pp.meta.jsonLd);
|
2026-08-06 23:42:25 +02:00
|
|
|
|
bool variantsOk = false;
|
2026-08-05 07:05:00 +02:00
|
|
|
|
if (ld && ld->IsObject()) {
|
2026-08-06 23:42:25 +02:00
|
|
|
|
if (const Json::Value* v = ld->Find("hasVariant");
|
|
|
|
|
|
v && v->IsArray() && v->array.size() == pr.variants.size()) {
|
|
|
|
|
|
variantsOk = true;
|
|
|
|
|
|
for (const Json::Value& node : v->array) {
|
|
|
|
|
|
const Json::Value* o = node.Find("offers");
|
|
|
|
|
|
variantsOk = variantsOk && node.Str("@type") == "Product"
|
|
|
|
|
|
&& o && o->IsObject();
|
|
|
|
|
|
}
|
2026-08-05 07:05:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-06 23:42:25 +02:00
|
|
|
|
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
|
|
|
|
|
|
"schema: product JSON-LD parses, one variant per colour");
|
|
|
|
|
|
// Merchant-grade fields: shipping, returns, sku, group id —
|
|
|
|
|
|
// what Merchant Center's website-crawl feed reads at launch
|
|
|
|
|
|
// (productGroupID is its item_group_id).
|
2026-08-05 23:52:40 +02:00
|
|
|
|
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
|
|
|
|
|
|
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
2026-08-06 23:42:25 +02:00
|
|
|
|
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
|
|
|
|
|
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
|
|
|
|
|
"schema: variants carry shipping, returns, sku and group id");
|
2026-08-05 07:05:00 +02:00
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
Check(!Content::Projects().empty(), "content: projects present");
|
|
|
|
|
|
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
2026-08-05 07:05:00 +02:00
|
|
|
|
Check(Content::AboutPage().sections.size() >= 3
|
|
|
|
|
|
&& !Content::AboutPage().sections[0].body.empty()
|
|
|
|
|
|
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
|
|
|
|
|
|
!= std::string::npos,
|
|
|
|
|
|
"content: about page names the founder");
|
2026-08-05 04:18:37 +02:00
|
|
|
|
Check(!Content::Demos().empty(), "content: demos present");
|
|
|
|
|
|
}
|
2026-08-06 23:42:25 +02:00
|
|
|
|
|
|
|
|
|
|
// ── the identity graph ────────────────────────────────────────────
|
|
|
|
|
|
// "Catcrafts" is two common words with no space, so it competes with a
|
|
|
|
|
|
// decade of kids' craft blogs, Etsy and a Minecraft server on the
|
|
|
|
|
|
// singular domain. The way out is not prose: it is one registered entity
|
|
|
|
|
|
// that every page points at by @id. Those joins are worth asserting
|
|
|
|
|
|
// because breaking one is silent — a typo'd @id still renders, still
|
|
|
|
|
|
// validates as JSON-LD, and still splits the graph back into three
|
|
|
|
|
|
// same-named strangers, which is the exact failure this markup exists to
|
|
|
|
|
|
// prevent.
|
|
|
|
|
|
{
|
|
|
|
|
|
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
|
|
|
|
|
|
|
|
|
|
|
|
auto home = Views::RenderHome(Content::Projects(), std::span<const Post>{});
|
|
|
|
|
|
auto ld = Json::Parse(home.meta.jsonLd);
|
|
|
|
|
|
const Json::Value* org = nullptr;
|
|
|
|
|
|
const Json::Value* site = nullptr;
|
|
|
|
|
|
if (ld && ld->IsObject()) {
|
|
|
|
|
|
if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) {
|
|
|
|
|
|
for (const Json::Value& node : g->array) {
|
|
|
|
|
|
if (node.Str("@type") == "Organization") org = &node;
|
|
|
|
|
|
if (node.Str("@type") == "WebSite") site = &node;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(org && site, "schema: home graph parses, carries Organization and WebSite");
|
|
|
|
|
|
|
|
|
|
|
|
if (org && site) {
|
|
|
|
|
|
Check(org->Str("@id") == kOrgId, "schema: organization node is identified");
|
|
|
|
|
|
Check(site->Str("@id") == "https://catcrafts.net/#website",
|
|
|
|
|
|
"schema: website node is identified");
|
|
|
|
|
|
// The join that makes two nodes one entity rather than two.
|
|
|
|
|
|
const Json::Value* publisher = site->Find("publisher");
|
|
|
|
|
|
Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId,
|
|
|
|
|
|
"schema: website is published by the organization node");
|
|
|
|
|
|
// The navigational query "catcrafts" is answered from the site
|
|
|
|
|
|
// entity, so the spellings people type belong on it.
|
|
|
|
|
|
const Json::Value* alt = site->Find("alternateName");
|
|
|
|
|
|
Check(alt && alt->IsArray() && !alt->array.empty(),
|
|
|
|
|
|
"schema: website carries the spellings people type");
|
|
|
|
|
|
|
|
|
|
|
|
// Registry numbers, typed. These are the part no name-twin can
|
|
|
|
|
|
// produce — each one is checkable against a public register,
|
|
|
|
|
|
// which also means a wrong value is worse than no value.
|
|
|
|
|
|
bool kvk = false, vat = false, eori = false;
|
|
|
|
|
|
if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) {
|
|
|
|
|
|
for (const Json::Value& id : ids->array) {
|
|
|
|
|
|
if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059";
|
|
|
|
|
|
if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38";
|
|
|
|
|
|
if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact");
|
|
|
|
|
|
|
|
|
|
|
|
// The name-twin guard. "Cat Crafts" with a space is the generic
|
|
|
|
|
|
// craft phrase owned by everyone else; claiming it as an alternate
|
|
|
|
|
|
// name argues for merging this entity into the corpus it needs to
|
|
|
|
|
|
// stay distinct from.
|
|
|
|
|
|
Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos,
|
|
|
|
|
|
"schema: the spaced generic is not claimed as a brand name");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cross-page joins: both must name the SAME @id the home page defines.
|
|
|
|
|
|
auto about = Views::RenderAbout(Content::AboutPage());
|
|
|
|
|
|
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
|
|
|
|
|
|
"schema: about joins the founder to the organization node");
|
|
|
|
|
|
if (!Content::Products().empty()) {
|
|
|
|
|
|
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
|
|
|
|
|
|
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
|
|
|
|
|
|
"schema: offers are sold by the organization node");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The person join, same mechanism in the other direction: home's
|
|
|
|
|
|
// founder and about's mainEntity must name one Person node, or "who
|
|
|
|
|
|
// founded Catcrafts" splits into two same-named strangers too.
|
|
|
|
|
|
constexpr std::string_view kPersonId = "https://catcrafts.net/about#person";
|
|
|
|
|
|
Check(home.meta.jsonLd.find(kPersonId) != std::string::npos
|
|
|
|
|
|
&& about.meta.jsonLd.find(kPersonId) != std::string::npos,
|
|
|
|
|
|
"schema: founder and about name one Person node");
|
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
|
|
// ── 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<std::string_view> args(argv + 1, argv + argc);
|
|
|
|
|
|
const auto has = [&](std::string_view f) {
|
|
|
|
|
|
return std::find(args.begin(), args.end(), f) != args.end();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 <path>: emit the full server-rendered document for a route.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This is the SSR path in miniature, and it is how the markup gets
|
|
|
|
|
|
// inspected without a browser: same renderers, same content files, same
|
|
|
|
|
|
// output the server will eventually put on the wire.
|
|
|
|
|
|
if (args.size() >= 2 && args[0] == "--render") {
|
|
|
|
|
|
const Views::SiteContent content = LoadContent("content");
|
|
|
|
|
|
const Route route = ParseRoute(args[1]);
|
|
|
|
|
|
const Views::RenderedPage page = Views::RenderRoute(route, content);
|
|
|
|
|
|
std::print("{}", Views::RenderDocument(
|
|
|
|
|
|
page,
|
|
|
|
|
|
Views::RenderNav(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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
|
|
|
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
|
|
|
|
|
for (std::string_view p : SitemapPaths()) {
|
|
|
|
|
|
std::print(" <url><loc>https://catcrafts.net{}</loc></url>\n",
|
|
|
|
|
|
Html::Escape(p).Str());
|
|
|
|
|
|
}
|
|
|
|
|
|
// Product URLs come from the loaded catalogue rather than a second
|
|
|
|
|
|
// hardcoded list, so the sitemap cannot advertise a product that does
|
|
|
|
|
|
// not exist or miss one that does.
|
|
|
|
|
|
for (const Product& pr : content.products) {
|
|
|
|
|
|
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
|
|
|
|
|
|
Html::Escape(pr.slug).Str());
|
|
|
|
|
|
}
|
|
|
|
|
|
std::print("</urlset>\n");
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (has("--feed")) {
|
|
|
|
|
|
const Views::SiteContent content = LoadContent("content");
|
|
|
|
|
|
std::print("{}", Views::RenderAtomFeed(content.posts));
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --routes: status + title for every route, for a quick smoke check.
|
|
|
|
|
|
if (has("--routes")) {
|
|
|
|
|
|
const Views::SiteContent content = LoadContent("content");
|
2026-08-05 07:05:00 +02:00
|
|
|
|
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
2026-08-05 04:18:37 +02:00
|
|
|
|
"/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<std::uint16_t>(parsed);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::println(std::cerr, "--serve: unrecognised argument '{}'", a);
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The bundle's index.html supplies the <script> tags with their
|
|
|
|
|
|
// per-build ?v= cache buster, which is why they are read rather than
|
|
|
|
|
|
// hardcoded — a hardcoded tag would silently serve a stale module.
|
|
|
|
|
|
//
|
|
|
|
|
|
// A missing bundle is NOT fatal: every route except /demo renders
|
|
|
|
|
|
// completely without the wasm, so the site degrades to plain SSR
|
|
|
|
|
|
// instead of refusing to start.
|
|
|
|
|
|
std::filesystem::path bundleIndex;
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
if (!webroot.empty()) {
|
|
|
|
|
|
bundleIndex = webroot / "index.html";
|
|
|
|
|
|
if (!std::filesystem::exists(bundleIndex, ec)) bundleIndex.clear();
|
|
|
|
|
|
} else if (std::filesystem::is_directory("bin", ec)) {
|
|
|
|
|
|
for (const auto& e : std::filesystem::directory_iterator("bin", ec)) {
|
|
|
|
|
|
if (e.is_directory() && e.path().filename().string().starts_with("Catcrafts.Net-")) {
|
|
|
|
|
|
bundleIndex = e.path() / "index.html";
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bundleIndex.empty()) {
|
|
|
|
|
|
std::println(std::cerr,
|
|
|
|
|
|
"catcrafts-server: no wasm bundle index.html found; "
|
|
|
|
|
|
"/demo will render without the renderer");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!std::filesystem::is_directory(contentDir, ec)) {
|
|
|
|
|
|
std::println(std::cerr, "catcrafts-server: content directory '{}' not found",
|
|
|
|
|
|
contentDir.string());
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Server::SetOrdersPath(ordersPath);
|
|
|
|
|
|
Server::LoadContent(contentDir, bundleIndex);
|
|
|
|
|
|
// Refuse to serve an empty catalogue: it almost always means the
|
|
|
|
|
|
// content path is wrong or a JSON file is malformed, and a silently
|
|
|
|
|
|
// empty projects page looks like a design choice rather than a bug.
|
|
|
|
|
|
if (Server::ContentProjectCount() == 0) {
|
|
|
|
|
|
std::println(std::cerr,
|
|
|
|
|
|
"catcrafts-server: no projects loaded from '{}' — refusing to start",
|
|
|
|
|
|
contentDir.string());
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The rail. State (bunq session context, or the fake rail's paid
|
|
|
|
|
|
// marker; Mollie needs none) defaults next to the orders file — same
|
|
|
|
|
|
// directory, same lifecycle, same backup.
|
|
|
|
|
|
if (railState.empty()) {
|
|
|
|
|
|
railState = ordersPath;
|
|
|
|
|
|
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
|
|
|
|
|
}
|
|
|
|
|
|
Server::RailConfig railCfg;
|
|
|
|
|
|
railCfg.mode = railMode;
|
|
|
|
|
|
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
|
|
|
|
|
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
|
|
|
|
|
: "";
|
|
|
|
|
|
railCfg.sandbox = bunqSandbox;
|
|
|
|
|
|
railCfg.statePath = railState;
|
|
|
|
|
|
railCfg.redirectBase = redirectBase;
|
|
|
|
|
|
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
|
|
|
|
|
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
|
|
|
|
|
std::println(std::cerr,
|
|
|
|
|
|
"catcrafts-server: --rail={} but its API key env is not set — "
|
|
|
|
|
|
"refusing to start with a rail that cannot work", railMode);
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Server::ConfigurePayments(std::move(rail), redirectBase);
|
|
|
|
|
|
|
|
|
|
|
|
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
|
|
|
|
|
// keyring. Unset means unsigned dev invoices with a visible marker.
|
|
|
|
|
|
if (const char* v = std::getenv("INVOICE_GPG_KEY"); v && *v) {
|
|
|
|
|
|
Server::ConfigureInvoicing(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sendcloud is optional: without credentials the compiled-in zone
|
|
|
|
|
|
// table prices all shipping, which is exactly how dev and e2e run.
|
|
|
|
|
|
// With credentials the refresh thread fetches per-country rates.
|
|
|
|
|
|
Server::ShippingConfig shipCfg;
|
|
|
|
|
|
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
|
|
|
|
|
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
|
|
|
|
|
if (const char* v = std::getenv("SENDCLOUD_METHOD")) shipCfg.methodName = v;
|
|
|
|
|
|
shipCfg.cachePath = ordersPath;
|
|
|
|
|
|
shipCfg.cachePath += ".shipping.json";
|
|
|
|
|
|
Server::ConfigureShipping(shipCfg);
|
|
|
|
|
|
|
|
|
|
|
|
return Server::Serve(port);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
|
|
|
|
|
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
|
|
|
|
|
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
|
|
|
|
|
if (!args.empty() && args[0] == "--orders") {
|
|
|
|
|
|
std::filesystem::path file = "orders.jsonl";
|
|
|
|
|
|
std::string markPaid, markShipped, cancel;
|
|
|
|
|
|
for (std::size_t i = 1; i < args.size(); ++i) {
|
|
|
|
|
|
const std::string_view a = args[i];
|
|
|
|
|
|
auto next = [&]() -> std::string {
|
|
|
|
|
|
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
|
|
|
|
|
|
};
|
|
|
|
|
|
if (a == "--mark-paid") markPaid = next();
|
|
|
|
|
|
else if (a == "--mark-shipped") markShipped = next();
|
|
|
|
|
|
else if (a == "--cancel") cancel = next();
|
|
|
|
|
|
else file = a;
|
|
|
|
|
|
}
|
|
|
|
|
|
Server::SetOrdersPath(file);
|
|
|
|
|
|
|
|
|
|
|
|
auto transition = [&](const std::string& token, std::string_view status) -> int {
|
|
|
|
|
|
auto order = Server::FindOrder(token);
|
|
|
|
|
|
if (!order) {
|
|
|
|
|
|
std::println(std::cerr, "no such order: {}", token);
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
const std::string now = std::format(
|
|
|
|
|
|
"{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
|
|
|
|
|
std::chrono::system_clock::now()));
|
|
|
|
|
|
if (!Server::AppendOrderStatus(token, status, now)) {
|
|
|
|
|
|
std::println(std::cerr, "could not append to {}", file.string());
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (status == "paid") Server::AssignInvoiceNumber(token, now);
|
|
|
|
|
|
std::println("{}: {} -> {}", order->reference, order->status, status);
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!markPaid.empty()) return transition(markPaid, "paid");
|
|
|
|
|
|
if (!markShipped.empty()) return transition(markShipped, "shipped");
|
|
|
|
|
|
if (!cancel.empty()) return transition(cancel, "cancelled");
|
|
|
|
|
|
|
|
|
|
|
|
const auto orders = Server::ListOrders();
|
|
|
|
|
|
std::println("orders: {}", orders.size());
|
|
|
|
|
|
if (orders.empty()) return 0;
|
|
|
|
|
|
std::println("");
|
|
|
|
|
|
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
|
|
|
|
|
"reference", "status", "total", "cc", "colour", "qty", "via",
|
|
|
|
|
|
"created", "token");
|
|
|
|
|
|
for (const auto& o : orders) {
|
|
|
|
|
|
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
|
|
|
|
|
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
|
|
|
|
|
o.buyer.country, o.color.empty() ? "-" : o.color,
|
|
|
|
|
|
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
|
|
|
|
|
o.createdAt, o.token);
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
|
|
|
|
|
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
|
|
|
|
|
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
|
|
|
|
|
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
|
|
|
|
|
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD");
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|