catcrafts.net/server/implementations/main.cpp
Jorijn van der Graaf 6841623e23
Some checks failed
Deploy / build-deploy (push) Failing after 5m49s
posts and avif
2026-08-10 01:37:26 +02:00

1524 lines
86 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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&lt;b", "escape: lt");
CheckEq(Escape("a>b"), "a&gt;b", "escape: gt");
CheckEq(Escape("a&b"), "a&amp;b", "escape: amp");
CheckEq(Escape("say \"hi\""), "say &quot;hi&quot;", "escape: dquote");
CheckEq(Escape("it's"), "it&#39;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;"), "&amp;lt;", "escape: no double-encode");
CheckEq(Escape("<script>alert(1)</script>"),
"&lt;script&gt;alert(1)&lt;/script&gt;", "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&quot;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&lt;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&lt;", "operator+: escapes preserved");
}
// The format ladder. One <picture>/<video> builder serves both the cards and
// the post bodies, so these assertions cover every image and video the site
// emits — and the ordering ones matter: a browser takes the FIRST source it
// understands, so a mis-ordered ladder silently serves the wrong tier to
// everyone rather than failing visibly.
void RunMediaSelfTest() {
auto img = [](std::string src, std::string avif, std::string png,
std::int64_t w = 0, std::int64_t h = 0) {
PostMedia m;
m.src = std::move(src);
m.kind = "image";
m.avif = std::move(avif);
m.fallback = std::move(png);
m.width = w;
m.height = h;
return m;
};
// The full ladder: AVIF, then the mirrored original, then the PNG the <img>
// itself points at. Exactly one of the three is ever fetched.
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png", 800, 600)),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<source srcset="/media/x.webp" type="image/webp">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png" width="800" height="600"></picture>)",
"media: image ladder is avif, original, png");
// Alt text reaches the <img>, not the <picture> — a screen reader reads the
// img, and an alt on the wrapper is invisible to it.
Check(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png"), "a cat")
.View().find(R"(alt="a cat" src="/media/x.png")") != std::string_view::npos,
"media: alt lands on the img");
// Degradation, one tier at a time. Each of these is a real state: no
// encoder on the build host, a source that was already PNG, a body image
// whose download failed so there is nothing but the original URL.
CheckEq(Media::Tag(img("/media/x.webp", "", "/media/x.png")),
R"(<picture><source srcset="/media/x.webp" type="image/webp">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png"></picture>)",
"media: no avif still offers the original above the png");
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "")),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.webp"></picture>)",
"media: no png leaves the original as the base");
CheckEq(Media::Tag(img("/media/x.webp", "", "")),
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.webp">)",
"media: no renditions is a bare img, as before any of this existed");
// A source that is already PNG is its own fallback, and must not be
// offered twice — once as a <source> and once as the <img>.
CheckEq(Media::Tag(img("/media/x.png", "/media/x.avif", "/media/x.png")),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png"></picture>)",
"media: a png source is not also listed as a source");
// Likewise a source that is already AVIF.
Check(Media::Tag(img("/media/x.avif", "/media/x.avif", "/media/x.png"))
.View().find("image/avif\"><source") == std::string_view::npos,
"media: an avif source is not listed twice");
// A URL is still a URL: the scheme allowlist applies to srcset exactly as
// it does to src, or the ladder becomes a way around it.
Check(Media::Tag(img("/media/x.webp", "javascript:alert(1)", "/media/x.png"))
.View().find(R"(srcset="#")") != std::string_view::npos,
"media: a hostile srcset is neutralised");
// Video is unchanged by any of this and must stay so.
{
PostMedia v;
v.src = "/media/v.mp4";
v.kind = "video";
v.poster = "/media/v.webp";
v.fallback = "/media/v.h264.mp4";
const auto out = Media::Tag(v);
Check(out.View().starts_with("<video class=\"post-media__item\" controls preload=\"metadata\""),
"media: video is still a video", out.View());
Check(out.View().find("codecs=av01") != std::string_view::npos
&& out.View().find(R"(<source src="/media/v.h264.mp4" type="video/mp4">)")
!= std::string_view::npos,
"media: AV1 then H.264, in that order");
Check(out.View().find("<picture>") == std::string_view::npos,
"media: a video is not wrapped in a picture");
}
// A path with no record renders from the path alone — the mirror failed,
// and showing the picture beats dropping the paragraph's subject.
{
const std::array<PostMedia, 1> known{
img("/media/x.webp", "/media/x.avif", "/media/x.png") };
Check(Media::Find(known, "/media/x.webp") != nullptr, "media: found by src");
Check(Media::Find(known, "/media/nope.webp") == nullptr, "media: unknown src");
const PostMedia guessed = Media::Describe(known, "https://i.example/a.webp");
Check(guessed.kind == "image" && guessed.avif.empty(),
"media: an unmirrored image is described from its path");
Check(Media::Describe(known, "https://i.example/a.mp4").kind == "video",
"media: an unmirrored video is recognised as one");
}
}
// The Markdown renderer, which is the newest place untrusted text becomes
// markup — post bodies are fetched from someone else's server, so every one of
// these assertions is ultimately about the same thing: nothing in a body can
// escape into the document. The structural cases are here too, because a parser
// that silently drops a construct loses content invisibly.
void RunMarkdownSelfTest() {
auto md = [](std::string_view text,
std::span<const PostMedia> media = {}) {
return Markdown::Render(text, media);
};
// ── the guarantee ─────────────────────────────────────────────────
CheckEq(md("<script>alert(1)</script>"),
"<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>", "md: html is text, never markup");
CheckEq(md("![x](javascript:alert(1))"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="x" src="#"></div>)",
"md: javascript: image source neutralised");
CheckEq(md("[x](javascript:alert(1))"),
R"(<p><a href="#" rel="noopener">x</a></p>)",
"md: javascript: link neutralised");
CheckEq(md("a \" b & c"), "<p>a &quot; b &amp; c</p>", "md: quotes and ampersands escaped");
// A code span is verbatim text, and verbatim is exactly where an escaper
// is most often forgotten.
CheckEq(md("`<b>`"), "<p><code>&lt;b&gt;</code></p>", "md: code span escaped");
// ── blocks ────────────────────────────────────────────────────────
CheckEq(md(""), "", "md: empty body renders nothing");
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
// Demotion by one: the page h1 is the post title, so a body's own top-level
// heading is a section within it.
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
CheckEq(md("> quoted"),
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
"md: blockquote");
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
// paragraphs instead of collapsing into one run-on line.
CheckEq(md("> one\n>\n> two"),
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
"md: blockquote keeps its paragraphs");
CheckEq(md("- a\n- b"),
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
CheckEq(md("1. a\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
// A list resumed after an interrupting paragraph continues its numbering.
// Without the start attribute the mini-guide in one of these posts renders
// as steps 1-4 followed by steps 1, 2, 3.
CheckEq(md("5. e"),
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
"md: ordered list keeps the number it announced");
// Blank lines between items are spacing, not seven one-item lists.
CheckEq(md("1. a\n\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
"md: blank line inside a list does not split it");
CheckEq(md("---"), "<hr>", "md: thematic break");
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
// Whitespace in pasted terminal output is the content.
CheckEq(md("```\n a\tb\n```"),
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
"md: fenced code is verbatim");
// An unterminated fence must not swallow the document into nothing.
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
"md: unterminated fence still renders its content");
// ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form.
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
CheckEq(md("[label](https://x.example/y)"),
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
// Bare addresses are pasted constantly in these posts; leaving them inert
// would strip most of the outbound value out of the page.
CheckEq(md("see https://x.example/y"),
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
"md: bare URL autolinked");
// ── embedded media ────────────────────────────────────────────────
// A paragraph that is nothing but images becomes the same media block the
// cards use, rather than a <p> of pictures.
CheckEq(md("![a](/media/x.webp)"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
"md: image-only paragraph is a media block");
Check(md("text ![a](/media/x.webp)").View().starts_with("<p>text <img"),
"md: an image inside a sentence stays inline");
// Dimensions come from the sidecar list, because Markdown syntax has
// nowhere to carry them — and without them the prose below every
// screenshot jumps as the file arrives.
{
std::vector<PostMedia> media;
PostMedia img;
img.src = "/media/x.webp";
img.kind = "image";
img.avif = "/media/x.avif";
img.fallback = "/media/x.png";
img.width = 800;
img.height = 600;
media.push_back(img);
PostMedia vid;
vid.src = "/media/v.mp4";
vid.kind = "video";
vid.poster = "/media/v.poster.webp";
vid.fallback = "/media/v.h264.mp4";
vid.width = 1080;
vid.height = 1920;
media.push_back(vid);
const auto out = md("![a](/media/x.webp)", media);
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
"md: inline image carries its dimensions", out.View());
// Routed through :Media, so a body image gets the same format ladder a
// card image does rather than a second, plainer implementation.
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
!= std::string_view::npos
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
"md: inline image gets the avif/png ladder", out.View());
// An inline video gets the same treatment a headline one does,
// fallback source and all.
const auto vout = md("![](/media/v.mp4)", media);
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
&& vout.View().find("codecs=av01") != std::string_view::npos
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
"md: inline video gets poster and H.264 fallback", vout.View());
}
// ── termination ───────────────────────────────────────────────────
// Unbalanced delimiters are the classic way to hang a hand-written
// parser, and a body is input from someone else's server.
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
Check(!md("![](").View().empty(), "md: unclosed image terminates");
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
}
// Post pages: the routing, the loader's guard on what becomes a URL, and the
// schema.org joins that keep every post attributed to the one Organization and
// the one Person the rest of the site describes.
void RunPostSelfTest() {
// ── routing ───────────────────────────────────────────────────────
Check(ParseRoute("/posts").kind == RouteKind::Posts, "route: /posts is the list");
Check(ParseRoute("/posts/hello-world").kind == RouteKind::Post, "route: /posts/<slug>");
Check(ParseRoute("/posts/hello-world").slug == "hello-world", "route: post slug captured");
Check(ParseRoute("/posts/hello-world/").kind == RouteKind::Post,
"route: trailing slash normalised");
Check(ParseRoute("/posts/Hello").kind == RouteKind::NotFound,
"route: uppercase slug is not a post URL");
Check(ParseRoute("/posts/../etc").kind == RouteKind::NotFound,
"route: traversal never reaches a lookup");
Check(NavKindFor(RouteKind::Post) == RouteKind::Posts,
"route: a post page highlights the Posts nav entry");
Check(NavKindFor(RouteKind::LegacyBlog) == RouteKind::Posts,
"route: the retired /blog URL highlights it too");
Check(NavKindFor(RouteKind::Shop) == RouteKind::Shop, "route: a nav route is its own entry");
// ── the loader ────────────────────────────────────────────────────
{
const auto posts = LoadPosts(R"([
{"title":"Good","slug":"good-post","permalink":"https://i.example/post/1",
"body":"Hello.","published":"2026-01-01T00:00:00Z",
"body_media":[{"src":"/media/a.webp","kind":"image","w":10,"h":20}]},
{"title":"Bad slug","slug":"NOT A SLUG","permalink":"https://i.example/post/2",
"body":"Hello."},
{"title":"No body","slug":"no-body","permalink":"https://i.example/post/3"}
])");
Check(posts.size() == 3, "posts: all three load");
if (posts.size() == 3) {
Check(posts[0].HasPage() && posts[0].slug == "good-post", "posts: valid slug kept");
Check(posts[0].body == "Hello.", "posts: body loaded");
Check(posts[0].bodyMedia.size() == 1 && posts[0].bodyMedia[0].width == 10,
"posts: body media loaded with dimensions");
// A slug that could never match a route would render a "read more"
// link to a 404 this site points at itself.
Check(posts[1].slug.empty() && !posts[1].HasPage(),
"posts: malformed slug is dropped, costing the page");
// A title and a link out is not a page worth minting a URL for.
Check(!posts[2].HasPage(), "posts: no body means no page");
}
Views::SiteContent content;
content.posts = posts;
Check(content.FindPost("good-post") != nullptr, "posts: found by slug");
Check(content.FindPost("no-body") == nullptr, "posts: a pageless post is not findable");
Check(content.FindPost("nope") == nullptr, "posts: unknown slug is not found");
// Which means the route 404s rather than rendering an empty article.
Check(Views::RenderRoute(ParseRoute("/posts/no-body"), content).status == 404,
"posts: a pageless slug is a real 404");
Check(Views::RenderRoute(ParseRoute("/posts/good-post"), content).status == 200,
"posts: a real post renders");
}
// ── the page ──────────────────────────────────────────────────────
{
Post p;
p.title = "Working GPS!";
p.slug = "working-gps";
p.permalink = "https://lemmy.example/post/42";
p.community = "linuxphones@lemmy.example";
p.published = "2026-06-26T23:16:25Z";
p.excerpt = "A short summary.";
p.body = "# How\n\nIt works.";
PostMedia m;
m.src = "/media/shot.webp";
m.kind = "image";
p.media.push_back(m);
const auto page = Views::RenderPost(p);
Check(page.meta.canonical == "/posts/working-gps",
"post page: canonical is this site, not the instance");
Check(page.meta.ogType == "article", "post page: og:type is article");
Check(page.meta.ogImage == "/media/shot.webp", "post page: og:image from the post media");
Check(page.meta.description == p.excerpt, "post page: description is the excerpt");
Check(page.main.View().find("<h2>How</h2>") != std::string_view::npos,
"post page: the body is rendered, not escaped away");
// The whole reason the body is hosted: the thread is still one click
// away, and the reader is told where the discussion is.
Check(page.main.View().find(p.permalink) != std::string_view::npos,
"post page: still links the thread");
// The identity graph, same joins every other page makes. A typo'd @id
// still renders and still validates — it just quietly splits this post
// away from the entity the rest of the site describes.
auto ld = Json::Parse(page.meta.jsonLd);
Check(ld && ld->IsObject() && ld->Str("@type") == "BlogPosting",
"post schema: parses as a BlogPosting");
if (ld && ld->IsObject()) {
Check(ld->Str("url") == "https://catcrafts.net/posts/working-gps",
"post schema: url is the on-site page");
Check(ld->Str("discussionUrl") == p.permalink,
"post schema: the thread is the discussion, not the content");
Check(ld->Str("datePublished") == p.published, "post schema: publication date");
const Json::Value* author = ld->Find("author");
const Json::Value* publisher = ld->Find("publisher");
Check(author && author->Str("@id") == "https://catcrafts.net/about#person",
"post schema: authored by the Person node on /about");
Check(publisher && publisher->Str("@id") == "https://catcrafts.net/#organization",
"post schema: published by the Organization node");
}
// A post with a page is advertised as one from the list and from home;
// one without keeps pointing at the thread, because there is nothing
// here to send the reader to.
const std::array<Post, 1> one{ p };
const auto list = Views::RenderPosts(one);
Check(list.main.View().find(R"(href="/posts/working-gps")") != std::string_view::npos,
"posts list: links the on-site page");
Check(list.main.View().find("Read the full post") != std::string_view::npos,
"posts list: offers the full post");
// Inside the excerpt paragraph, trailing the text — not a row of its
// own below the media, where it was the same offer made a screen
// further down.
Check(list.main.View().find(
R"(A short summary. <a class="link-more" href="/posts/working-gps">)"
R"(Read the full post</a></p>)") != std::string_view::npos,
"posts list: read-more trails the excerpt", list.main.View());
// With no excerpt there is no sentence to continue, so it falls back to
// a row rather than vanishing with the paragraph that would have held it.
Post unexcerpted = p;
unexcerpted.excerpt.clear();
const std::array<Post, 1> bare{ unexcerpted };
const auto listBare = Views::RenderPosts(bare);
Check(listBare.main.View().find("Read the full post") != std::string_view::npos,
"posts list: an excerptless post still offers the full post");
Post bodyless = p;
bodyless.body.clear();
const std::array<Post, 1> none{ bodyless };
const auto listNone = Views::RenderPosts(none);
Check(listNone.main.View().find("Read the full post") == std::string_view::npos,
"posts list: no read-more without a page to read");
Check(listNone.main.View().find(p.permalink) != std::string_view::npos,
"posts list: a pageless post still links its thread");
}
}
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");
// The product page's schema.org record must parse with our own
// JSON parser and carry one variant Product per colour, each
// with its ONE offer — built from the same integers the
// checkout charges.
{
auto pp = Views::RenderProduct(pr, Rates{});
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
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();
}
}
}
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).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& 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");
}
}
Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages");
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");
Check(!Content::Demos().empty(), "content: demos present");
}
// ── 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");
}
// ── 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");
// ── the order confirmation email ──────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
o.vatIncluded = true;
o.buyer.country = "NL";
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
const std::string mail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
"email: From header");
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
"email: subject carries the reference");
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
"email: date header");
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
!= std::string::npos,
"email: message id from the token");
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
"email: item names colour and quantity");
Check(mail.find("€1135.23") != std::string::npos, "email: total");
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
!= std::string::npos,
"email: order page link");
Check(mail.find("filename=\"catcrafts-invoice-"
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
"email: attachment filename is the invoice number");
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
"email: attachment body verbatim");
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
"email: multipart closes");
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "CA";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(exMail.find("zero-rated export") != std::string::npos,
"email: export VAT wording");
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
// A single unit does not advertise a quantity.
o.quantity = 1;
const std::string one = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
// The last line of defence: an address that could smuggle a header
// yields NO message at all, however it got into the record.
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
}
// ── 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();
RunMediaSelfTest();
RunMarkdownSelfTest();
RunPostSelfTest();
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(NavKindFor(route.kind)),
Views::RenderFooter(),
/*bootScripts=*/"", // no wasm on a plain server render
/*cssHref=*/"/styles.css"));
return 0;
}
// --sitemap / --feed: generated from the same route table and Post model
// the pages use, so they cannot drift from what the site actually serves.
// The checked-in sitemap.xml this replaces still listed three blog posts
// that no longer exist.
//
// Html::Escape's output is valid XML text: &amp; &lt; &gt; &quot; are
// shared with XML, and it emits an apostrophe as the numeric reference
// &#39; rather than the HTML-only &apos;. So no separate XML escaper.
if (has("--sitemap")) {
const Views::SiteContent content = LoadContent("content");
std::print("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (std::string_view p : SitemapPaths()) {
std::print(" <url><loc>https://catcrafts.net{}</loc></url>\n",
Html::Escape(p).Str());
}
// Product URLs come from the loaded catalogue rather than a second
// hardcoded list, so the sitemap cannot advertise a product that does
// not exist or miss one that does.
for (const Product& pr : content.products) {
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
Html::Escape(pr.slug).Str());
}
// Same rule as the served sitemap: only posts that actually have a
// page. Both must agree, because this is the copy baked into the wasm
// bundle and that one is what a crawler fetches.
for (const Post& po : content.posts) {
if (!po.HasPage()) continue;
std::print(" <url><loc>https://catcrafts.net/posts/{}</loc></url>\n",
Html::Escape(po.slug).Str());
}
std::print("</urlset>\n");
return 0;
}
if (has("--feed")) {
const Views::SiteContent content = LoadContent("content");
std::print("{}", Views::RenderAtomFeed(content.posts));
return 0;
}
// --routes: status + title for every route, for a quick smoke check.
if (has("--routes")) {
const Views::SiteContent content = LoadContent("content");
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
"/order/0123456789abcdef0123456789abcdef",
"/order/not-a-token",
"/legal/privacy", "/legal/imprint",
"/legal/terms", "/legal/nope",
"/projects", "/posts", "/posts/nope", "/demos",
"/demos/raytracer", "/demos/nope", "/demo",
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
const Route r = ParseRoute(p);
const Views::RenderedPage page = Views::RenderRoute(r, content);
std::println("{:<22} status={} bytes={:<6} title={}",
p, page.status, page.main.Size(), page.meta.title);
}
return 0;
}
// --serve [port] [--content=DIR] [--webroot=DIR]
//
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
// Catcrafts.Server-Http.cpp for why not HTTP/3.
//
// Both directories are options rather than fixed paths because the
// development layout and the deployed layout differ: in the repo the
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
// while on the server the content is installed next to the binary and the
// bundle IS the webroot Caddy serves.
if (!args.empty() && args[0] == "--serve") {
std::uint16_t port = 8081;
std::filesystem::path contentDir = "content";
std::filesystem::path webroot;
// Default alongside the content in dev; the systemd unit points this at
// /var/lib/catcrafts, which is deliberately NOT the web root — that
// directory is publicly served and wiped by rsync --delete each deploy.
std::filesystem::path ordersPath = "orders.jsonl";
// Payment rail selection. 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);
}
// Order email: a sendmail-compatible command ("msmtp -t" on the
// server) that reads the message on stdin and takes the recipient
// from its headers. Unset means no email is sent — the order page
// and invoice download remain the buyer's receipt.
{
Server::MailConfig mailCfg;
if (const char* v = std::getenv("MAIL_COMMAND"); v && *v) mailCfg.command = v;
if (const char* v = std::getenv("MAIL_FROM"); v && *v) mailCfg.from = v;
Server::ConfigureMail(std::move(mailCfg));
}
// Sendcloud is 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,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
return 0;
}