This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(cd ../Crafter/Crafter.Graphics && echo \"=== C++ files declaring wgpu* imports ===\"; grep -rln 'import_name\\(\"wgpu' implementations/ interfaces/; echo; echo \"=== sample: how a wgpu import + public wrapper is declared \\(canvas size + a string-taking one\\) ===\"; grep -rn -B2 -A2 -E 'import_name\\\\\\(\"wgpu\\(GetCanvasWidth|SurfaceWidth|LoadCustomShader|Init\\)\"' implementations/ interfaces/ | head -50)",
|
|
||||||
"Read(//home/jorijn/repos/Crafter/Crafter.Graphics/**)",
|
|
||||||
"Read(//home/jorijn/repos/Crafter/Crafter.Graphics/interfaces/**)",
|
|
||||||
"Read(//home/jorijn/repos/Crafter/Crafter.Graphics/additional/**)",
|
|
||||||
"Bash(crafter-build --local)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/home/jorijn/repos/Crafter/Crafter.Graphics/additional",
|
|
||||||
"/home/jorijn/repos/Crafter/Crafter.Graphics/interfaces"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,11 +27,17 @@ jobs:
|
||||||
# run inside this archlinux container — the runner execs them with
|
# run inside this archlinux container — the runner execs them with
|
||||||
# node. This shell step needs no node, so installing it here (before
|
# node. This shell step needs no node, so installing it here (before
|
||||||
# Checkout) is enough.
|
# Checkout) is enough.
|
||||||
|
# ffmpeg is for ffprobe, which tools/fetch-media.sh uses to read the
|
||||||
|
# pixel dimensions of each mirrored file. Those become the width/height
|
||||||
|
# attributes that stop the posts page reflowing as 5 MB recordings
|
||||||
|
# arrive, and tools/e2e.sh asserts they are present — so without this
|
||||||
|
# package the deploy fails at the e2e gate rather than shipping a
|
||||||
|
# janky page.
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
nodejs \
|
nodejs \
|
||||||
clang lld libc++ \
|
clang lld libc++ \
|
||||||
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
|
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
|
||||||
git curl tar rsync
|
git curl tar rsync zstd gzip jq openssl ffmpeg gnupg
|
||||||
# Container runs as root; workspace may be owned by another uid.
|
# Container runs as root; workspace may be owned by another uid.
|
||||||
git config --global --add safe.directory '*'
|
git config --global --add safe.directory '*'
|
||||||
|
|
||||||
|
|
@ -63,6 +69,79 @@ jobs:
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
crafter-cache-${{ runner.os }}-
|
crafter-cache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Fetch ECB reference rates
|
||||||
|
# Feeds the indicative national-currency line on order pages. Every
|
||||||
|
# charge is in euros; this is display only, labelled with its date —
|
||||||
|
# which is why build-time freshness is enough and no rate service is
|
||||||
|
# ever called at page-view time. Exits 0 on failure: a stale rate
|
||||||
|
# (or none — the page then shows only euros) must not fail a deploy.
|
||||||
|
run: tools/fetch-rates.sh
|
||||||
|
|
||||||
|
- name: Fetch fediverse posts
|
||||||
|
# Build-time, not run-time: the site embeds the owner's own posts and
|
||||||
|
# links out for discussion, so there is no sync service and no runtime
|
||||||
|
# dependency on the instance being up. The script leaves the committed
|
||||||
|
# content/posts.json untouched and exits 0 on any failure, so a
|
||||||
|
# fediverse outage cannot fail a deploy.
|
||||||
|
run: tools/fetch-posts.sh
|
||||||
|
|
||||||
|
- name: Mirror post media
|
||||||
|
# Downloads the images and screen recordings the posts carry and rewrites
|
||||||
|
# content/posts.json to point at our own copies, so nothing the browser
|
||||||
|
# loads is third-party — which is what keeps the privacy notice's
|
||||||
|
# "everything comes from catcrafts.net" true.
|
||||||
|
#
|
||||||
|
# Content-addressed and incremental: a file already on the media mount is
|
||||||
|
# never downloaded again. Writes straight into the mount so the copies
|
||||||
|
# persist across deploys — they are NOT always reproducible, because a
|
||||||
|
# source instance deleting a file leaves ours as the only one.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -d /deploy-app ]; then
|
||||||
|
mkdir -p /deploy-app/media
|
||||||
|
tools/fetch-media.sh /deploy-app/media
|
||||||
|
else
|
||||||
|
echo "WARNING: /deploy-app not mounted; mirroring to a throwaway dir." >&2
|
||||||
|
echo "Media will be re-downloaded on every build until the mount exists." >&2
|
||||||
|
tools/fetch-media.sh media
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and test the backend
|
||||||
|
id: srv
|
||||||
|
# The server product builds Catcrafts.Shared for the host, which is the
|
||||||
|
# only way to actually RUN the code that generates every byte of markup
|
||||||
|
# the site emits. --selftest is a gate: if escaping or the JSON reader
|
||||||
|
# regress, the deploy stops here rather than shipping broken pages.
|
||||||
|
#
|
||||||
|
# Same refuse-to-guess rule as the wasm bundle below: a variant
|
||||||
|
# directory embeds a config hash, so more than one match means the tree
|
||||||
|
# is ambiguous and picking the first would deploy an arbitrary build.
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
crafter-build -- --product=server
|
||||||
|
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort)
|
||||||
|
count=$(printf '%s\n' "$matches" | grep -c . || true)
|
||||||
|
if [ "$count" -ne 1 ]; then
|
||||||
|
echo "Expected exactly one Catcrafts.Server-* directory, found $count:" >&2
|
||||||
|
printf '%s\n' "$matches" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "srv=$matches" >> "$GITHUB_OUTPUT"
|
||||||
|
"$matches/catcrafts-server" --selftest
|
||||||
|
"$matches/catcrafts-server" --routes
|
||||||
|
|
||||||
|
- name: Generate sitemap and Atom feed
|
||||||
|
# Both come from the same route table and Post model the pages use, so
|
||||||
|
# they cannot drift from what the site serves. Generated BEFORE the wasm
|
||||||
|
# build so cfg.files picks them up into the bundle.
|
||||||
|
env:
|
||||||
|
SRV: ${{ steps.srv.outputs.srv }}
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
"$SRV/catcrafts-server" --sitemap > sitemap.xml
|
||||||
|
"$SRV/catcrafts-server" --feed > feed.xml
|
||||||
|
head -n 4 sitemap.xml
|
||||||
|
|
||||||
- name: Build (wasm bundle)
|
- name: Build (wasm bundle)
|
||||||
run: crafter-build
|
run: crafter-build
|
||||||
|
|
||||||
|
|
@ -70,16 +149,78 @@ jobs:
|
||||||
id: out
|
id: out
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
dist=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | head -n1)
|
# The directory name embeds a config hash, so glob for it. Any change
|
||||||
if [ -z "$dist" ]; then
|
# to compile/link flags produces a NEW hash, which is why we refuse to
|
||||||
|
# guess when more than one variant is present rather than taking
|
||||||
|
# whichever the filesystem happened to list first.
|
||||||
|
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | sort)
|
||||||
|
count=$(printf '%s\n' "$matches" | grep -c . || true)
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
echo "No build output directory found under bin/" >&2
|
echo "No build output directory found under bin/" >&2
|
||||||
ls -la bin || true
|
ls -la bin || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ "$count" -gt 1 ]; then
|
||||||
|
echo "Ambiguous build output — $count variant directories under bin/:" >&2
|
||||||
|
printf '%s\n' "$matches" >&2
|
||||||
|
echo "Refusing to guess which one to deploy. Clean bin/ and rebuild." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dist=$matches
|
||||||
echo "dist=$dist" >> "$GITHUB_OUTPUT"
|
echo "dist=$dist" >> "$GITHUB_OUTPUT"
|
||||||
echo "Built bundle: $dist"
|
echo "Built bundle: $dist"
|
||||||
ls -la "$dist"
|
ls -la "$dist"
|
||||||
|
|
||||||
|
- name: Make the static shell depth-safe
|
||||||
|
# Caddy serves this index.html directly when the backend is down, at
|
||||||
|
# whatever URL was requested — including two-segment ones like
|
||||||
|
# /demos/raytracer. Crafter.Build emits relative boot scripts and its
|
||||||
|
# runtime.js fetches variants.json/files.json/the wasm relative to the
|
||||||
|
# DOCUMENT, so at any depth the fallback loads nothing at all. The script
|
||||||
|
# roots the tags and adds <base href="/">, and fails loudly rather than
|
||||||
|
# silently no-opping. The SSR path handles itself; this is only the
|
||||||
|
# backend-down fallback.
|
||||||
|
env:
|
||||||
|
DIST: ${{ steps.out.outputs.dist }}
|
||||||
|
run: tools/fix-bundle-depth.sh "$DIST"
|
||||||
|
|
||||||
|
- name: End-to-end HTTP tests
|
||||||
|
# Starts the freshly built server on a scratch port and exercises it over
|
||||||
|
# real HTTP: status codes, redirects, headers, form submission, and the
|
||||||
|
# no-JavaScript guarantee. --selftest covers the pure functions; only a
|
||||||
|
# real request can show that /nope is a 404 rather than a soft 404, that
|
||||||
|
# /projects contains its content with no <script> at all, and that a
|
||||||
|
# rejected form comes back with the visitor's values still in it.
|
||||||
|
#
|
||||||
|
# Runs AFTER the wasm build, which is not cosmetic ordering. The server
|
||||||
|
# discovers the bundle under bin/ and lifts its <script> tags from it, so
|
||||||
|
# with no bundle present there is nothing to assert about wasm booting:
|
||||||
|
# "/demos/raytracer loads the wasm" fails outright, and every
|
||||||
|
# "ships no script" check passes vacuously because no page has scripts to
|
||||||
|
# begin with. Building first is what makes both meaningful.
|
||||||
|
#
|
||||||
|
# A gate, not a report: a failure here stops the deploy.
|
||||||
|
env:
|
||||||
|
SRV: ${{ steps.srv.outputs.srv }}
|
||||||
|
run: tools/e2e.sh "$SRV/catcrafts-server"
|
||||||
|
|
||||||
|
- name: Pre-compress static assets
|
||||||
|
# Caddy's `precompressed zstd gzip` (see deploy/Caddyfile.example)
|
||||||
|
# serves these siblings straight from disk instead of re-compressing
|
||||||
|
# the ~700 KB wasm module on every request. Building them here also
|
||||||
|
# buys a better ratio than on-the-fly encoding would spend CPU on.
|
||||||
|
env:
|
||||||
|
DIST: ${{ steps.out.outputs.dist }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for f in "$DIST"/*.wasm "$DIST"/*.js "$DIST"/*.css "$DIST"/*.xml "$DIST"/*.svg; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
zstd -19 -q -f -k -- "$f"
|
||||||
|
gzip -9 -f -k -- "$f"
|
||||||
|
done
|
||||||
|
echo "Compressed artifacts:"
|
||||||
|
ls -la "$DIST"
|
||||||
|
|
||||||
- name: Deploy to web root
|
- name: Deploy to web root
|
||||||
# No SSH: the job already runs on the deploy box. The server's web root
|
# No SSH: the job already runs on the deploy box. The server's web root
|
||||||
# is bind-mounted into this container at /deploy via the runner config
|
# is bind-mounted into this container at /deploy via the runner config
|
||||||
|
|
@ -99,3 +240,55 @@ jobs:
|
||||||
fi
|
fi
|
||||||
rsync -a --delete --exclude 'Caddyfile.coi' "$DIST"/ /deploy/
|
rsync -a --delete --exclude 'Caddyfile.coi' "$DIST"/ /deploy/
|
||||||
echo "Deployed $DIST -> /deploy (host web root)"
|
echo "Deployed $DIST -> /deploy (host web root)"
|
||||||
|
|
||||||
|
- name: Guard against leaking runtime files into the web root
|
||||||
|
# The web root is served by Caddy's file_server AND mirrored with
|
||||||
|
# --delete, so anything runtime-owned that lands there is both published
|
||||||
|
# and destroyed on the next push. Nothing does today; this is here so a
|
||||||
|
# future cfg.files line cannot quietly change that once the shop has a
|
||||||
|
# database and bank credentials.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
bad=$(find /deploy -maxdepth 1 \( -name '*.db' -o -name '*.db-*' \
|
||||||
|
-o -name '*.pem' -o -name '*.key' -o -name '*.env' \
|
||||||
|
-o -name '.*' ! -name '.' \) -print 2>/dev/null || true)
|
||||||
|
if [ -n "$bad" ]; then
|
||||||
|
echo "ERROR: runtime-owned files found in the public web root:" >&2
|
||||||
|
printf '%s\n' "$bad" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Web root contains no database, key or dotfile."
|
||||||
|
|
||||||
|
- name: Deploy backend
|
||||||
|
# Second bind mount, separate from the web root on purpose: the backend
|
||||||
|
# binary and its content must NOT be inside a directory Caddy serves and
|
||||||
|
# rsync --delete mirrors. Add to the runner's container.options:
|
||||||
|
# -v /srv/catcrafts-app:/deploy-app
|
||||||
|
#
|
||||||
|
# The restart is not done here. This job runs inside a container with no
|
||||||
|
# access to the host's systemd, and the alternatives (host root for the
|
||||||
|
# runner, or a polkit rule) grant CI far more than "restart one service"
|
||||||
|
# needs. Instead the last step touches a marker file that
|
||||||
|
# catcrafts-deploy.path watches — see deploy/catcrafts-deploy.path.
|
||||||
|
# That unit re-runs --selftest against the new binary before cutting
|
||||||
|
# over, so a broken deploy leaves the working server running.
|
||||||
|
env:
|
||||||
|
SRV: ${{ steps.srv.outputs.srv }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ ! -d /deploy-app ]; then
|
||||||
|
echo "ERROR: /deploy-app is not mounted into the runner container." >&2
|
||||||
|
echo "Add '-v /srv/catcrafts-app:/deploy-app' to the runner's" >&2
|
||||||
|
echo "container.options in config.yaml and restart the runner." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -m 0755 "$SRV/catcrafts-server" /deploy-app/catcrafts-server.new
|
||||||
|
mkdir -p /deploy-app/content
|
||||||
|
# --delete on content/ is safe: it is regenerated every build. Note it
|
||||||
|
# does NOT touch /deploy-app/media, which must survive.
|
||||||
|
rsync -a --delete content/ /deploy-app/content/
|
||||||
|
# Swap the binary into place atomically, so a request arriving mid-copy
|
||||||
|
# never hits a truncated executable.
|
||||||
|
mv -f /deploy-app/catcrafts-server.new /deploy-app/catcrafts-server
|
||||||
|
date -u +%FT%TZ > /deploy-app/.deploy-stamp
|
||||||
|
echo "Deployed backend -> /deploy-app; marker touched"
|
||||||
|
|
|
||||||
26
.gitignore
vendored
26
.gitignore
vendored
|
|
@ -1,2 +1,28 @@
|
||||||
build/
|
build/
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
|
# Generated by the server product before the wasm build, from the same route
|
||||||
|
# table and Post model the pages use (see .forgejo/workflows/deploy.yaml).
|
||||||
|
# Checked-in copies drift: the committed sitemap.xml that used to live here
|
||||||
|
# still listed three blog posts that no longer exist.
|
||||||
|
sitemap.xml
|
||||||
|
feed.xml
|
||||||
|
|
||||||
|
# Post media, mirrored from the source instances by tools/fetch-media.sh.
|
||||||
|
# Content-addressed, so re-running is cheap and only new files download. Kept out
|
||||||
|
# of git because it is tens of megabytes of binaries that are reproducible from
|
||||||
|
# content/posts.json — but see deploy/README.md: on the server it lives OUTSIDE
|
||||||
|
# the rsync --delete target, because a source instance deleting a file makes it
|
||||||
|
# unreproducible.
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Runtime order state — belongs in /var/lib/catcrafts in production and in a
|
||||||
|
# temp dir under dev.sh/e2e; never in the repo, never in the web root.
|
||||||
|
orders.jsonl
|
||||||
|
*.fake-paid
|
||||||
|
bunq-state.json
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Local Claude Code permissions — dev-machine tooling config, not project code.
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,59 @@
|
||||||
// Head-element setup for catcrafts.net. The Crafter.Graphics Dom partition
|
// Head-element setup for catcrafts.net.
|
||||||
// only exposes element creation under <body>, so anything that has to live in
|
//
|
||||||
// <head> (title, stylesheet link, favicon link) gets injected from this
|
// The Crafter.Graphics Dom partition only exposes element creation under <body>,
|
||||||
// loader. EnableWasiBrowserRuntime auto-picks up every *.js in cfg.files and
|
// so anything that has to live in <head> — title, stylesheet, favicon, viewport
|
||||||
// emits a <script type="module"> tag for it ahead of runtime.js, so this
|
// — is injected from this loader. EnableWasiBrowserRuntime emits a
|
||||||
// runs before the wasm module starts and styles are in place by first paint.
|
// <script type="module"> for every *.js in cfg.files ahead of runtime.js, so
|
||||||
|
// this runs before the wasm module starts and styles are in place by first
|
||||||
|
// paint.
|
||||||
|
//
|
||||||
|
// EVERYTHING HERE IS CONDITIONAL, and that matters. There are two ways a
|
||||||
|
// document reaches the browser:
|
||||||
|
//
|
||||||
|
// 1. Server-rendered by catcrafts-server, which already emitted a correct
|
||||||
|
// per-route <title> plus the stylesheet, favicon and viewport. It marks
|
||||||
|
// that with <meta name="cc-ssr">.
|
||||||
|
// 2. The static fallback shell — Caddy serves the wasm bundle's index.html
|
||||||
|
// when the backend is down. That document has a bare <head> and an empty
|
||||||
|
// <body>, so the app has to supply all of it.
|
||||||
|
//
|
||||||
|
// Running unconditionally broke case 1: it replaced the route's real title with
|
||||||
|
// the generic site name, and appended a second stylesheet, favicon and viewport
|
||||||
|
// tag. So each addition checks whether the server already did the job.
|
||||||
|
|
||||||
document.title = "Catcrafts.net";
|
const ssr = document.querySelector('meta[name="cc-ssr"]') !== null;
|
||||||
|
|
||||||
const styles = document.createElement("link");
|
function ensure(selector, build) {
|
||||||
styles.rel = "stylesheet";
|
if (document.querySelector(selector)) return;
|
||||||
styles.href = "styles.css";
|
document.head.appendChild(build());
|
||||||
document.head.appendChild(styles);
|
}
|
||||||
|
|
||||||
const favicon = document.createElement("link");
|
// Only claim the title when the server did not set one. On an SSR'd page the
|
||||||
favicon.rel = "icon";
|
// existing title is route-specific and strictly better than anything this file
|
||||||
favicon.type = "image/svg+xml";
|
// knows. "catcrafts.wasm" is what Crafter.Build's index.html template hardcodes,
|
||||||
favicon.href = "favicon.svg";
|
// so it counts as unset.
|
||||||
document.head.appendChild(favicon);
|
if (!ssr && (!document.title || document.title === "catcrafts.wasm")) {
|
||||||
|
document.title = "Catcrafts";
|
||||||
|
}
|
||||||
|
|
||||||
const viewport = document.createElement("meta");
|
ensure('link[rel="stylesheet"]', () => {
|
||||||
viewport.name = "viewport";
|
const el = document.createElement("link");
|
||||||
viewport.content = "width=device-width, initial-scale=1.0";
|
el.rel = "stylesheet";
|
||||||
document.head.appendChild(viewport);
|
el.href = "/styles.css";
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
ensure('link[rel="icon"]', () => {
|
||||||
|
const el = document.createElement("link");
|
||||||
|
el.rel = "icon";
|
||||||
|
el.type = "image/svg+xml";
|
||||||
|
el.href = "/favicon.svg";
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
ensure('meta[name="viewport"]', () => {
|
||||||
|
const el = document.createElement("meta");
|
||||||
|
el.name = "viewport";
|
||||||
|
el.content = "width=device-width, initial-scale=1";
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
|
||||||
28
content/posts-sources.json
Normal file
28
content/posts-sources.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"Which of my fediverse posts appear on this site.",
|
||||||
|
"",
|
||||||
|
"The Lemmy user API returns everything an account has posted, across every",
|
||||||
|
"community. That is not what belongs here — the account is also used for",
|
||||||
|
"communities that have nothing to do with the work, and a visitor reading",
|
||||||
|
"about mobile Linux has no reason to be shown them.",
|
||||||
|
"",
|
||||||
|
"So the fetch is filtered by community, not by account. Add a community here",
|
||||||
|
"and its posts show up; leave one out and they never do. Deliberately an",
|
||||||
|
"allowlist rather than a blocklist: joining a new community should not",
|
||||||
|
"silently publish to the site.",
|
||||||
|
"",
|
||||||
|
"Match is on the full 'name@instance' form, case-insensitive.",
|
||||||
|
"",
|
||||||
|
"Note that `username` is the account the API is queried for, and nothing",
|
||||||
|
"links to it. The account itself is not advertised anywhere on the site —",
|
||||||
|
"only the individual posts, each linking to its own thread."
|
||||||
|
],
|
||||||
|
|
||||||
|
"username": "TheMightyCat",
|
||||||
|
"instance": "https://ani.social",
|
||||||
|
|
||||||
|
"communities": [
|
||||||
|
"linuxphones@lemmy.ca"
|
||||||
|
]
|
||||||
|
}
|
||||||
146
content/posts.json
Normal file
146
content/posts.json
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Plasma-mobile working orca screenreader!",
|
||||||
|
"permalink": "https://lemmy.ca/post/68339519",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-07-25T00:45:24.764941Z",
|
||||||
|
"excerpt": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment: And this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone. My philosophy is that linux should be as…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/54d923869e19d71c.mp4",
|
||||||
|
"kind": "video",
|
||||||
|
"poster": "/media/63acea1959c6400d.webp",
|
||||||
|
"w": 1080,
|
||||||
|
"h": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 82,
|
||||||
|
"comments": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS working audio calls!!!",
|
||||||
|
"permalink": "https://lemmy.ca/post/68231524",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-07-22T21:36:18.030030Z",
|
||||||
|
"excerpt": "At first, man thinks he controls the modem. But after the 20th reboot, man learns the modem controls him. — Epictetus So here it is! working VoLTE calls with audio on linux! What a rollercoaster it has been i'll tell you that. The frequency between my previous posts was only a…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/8bc5b5a6565ba864.mp4",
|
||||||
|
"kind": "video",
|
||||||
|
"poster": "/media/7133dc70534bf47a.webp",
|
||||||
|
"w": 854,
|
||||||
|
"h": 480
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 501,
|
||||||
|
"comments": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS working mic!",
|
||||||
|
"permalink": "https://lemmy.ca/post/67400575",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-07-06T00:43:52.032860Z",
|
||||||
|
"excerpt": "My quest to 100% feature completeness continues! Today i bring before you... The working microphone! Now everyone is going to ask: Does this mean working calls? I asked the same thing, and with maybe a bit too much excitement i inserted my SIM card. But the answer is no, neither…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/8e0ef8ba7e9769ec.mp4",
|
||||||
|
"kind": "video",
|
||||||
|
"poster": "/media/7f1d353c81fa55e6.webp",
|
||||||
|
"w": 854,
|
||||||
|
"h": 480
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 296,
|
||||||
|
"comments": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS working audio!",
|
||||||
|
"permalink": "https://lemmy.ca/post/67308520",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-07-04T01:08:18.582204Z",
|
||||||
|
"excerpt": "Well that was easy. Here i was thinking audio was going to be the boss fight... These were small easily identifiable kernel changes, not the entire investigations that GPS and NFC needed. Ill be working to getting calls fully working, but this is already a big step. And speaking…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/f9d29d90b4c9c9b9.mp4",
|
||||||
|
"kind": "video",
|
||||||
|
"poster": "/media/07b79c6415f4c487.webp",
|
||||||
|
"w": 854,
|
||||||
|
"h": 480
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 165,
|
||||||
|
"comments": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "The linux phone travel experience",
|
||||||
|
"permalink": "https://lemmy.ca/post/67200311",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-07-01T16:15:57.088199Z",
|
||||||
|
"excerpt": "No new features today, just wanted to make a short post. It is completely possible to do linux development on the go! Is it convenient? Probably not. Is a laptop better? Most likely. Is it funny? Yes. Also a double table is definitely better: photo of a linux phone with a…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/1c75b19e98c7b6ef.webp",
|
||||||
|
"kind": "image",
|
||||||
|
"poster": "",
|
||||||
|
"w": 1920,
|
||||||
|
"h": 887
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 264,
|
||||||
|
"comments": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS working NFC reading! + mini guide",
|
||||||
|
"permalink": "https://lemmy.ca/post/67077865",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-06-28T23:58:53.301629Z",
|
||||||
|
"excerpt": "This is the continuation of my ongoing effort to get the FP6 fully feature complete with linux. I chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong. But alot of time later and as you can see in the video now reading…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/c8de4451ce9d5663.mp4",
|
||||||
|
"kind": "video",
|
||||||
|
"poster": "/media/c6a1a1a41b2883e5.webp",
|
||||||
|
"w": 854,
|
||||||
|
"h": 480
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 218,
|
||||||
|
"comments": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS working GPS!",
|
||||||
|
"permalink": "https://lemmy.ca/post/66988716",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-06-26T23:16:25.810172Z",
|
||||||
|
"excerpt": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS. This is the current component support table of postmarketos: FP6 feature support table i think audio is too complex for me, so i…",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"src": "/media/ce6475f10b3baeee.webp",
|
||||||
|
"kind": "image",
|
||||||
|
"poster": "",
|
||||||
|
"w": 887,
|
||||||
|
"h": 1920
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 471,
|
||||||
|
"comments": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fairphone 6 + PostmarketOS review",
|
||||||
|
"permalink": "https://lemmy.ca/post/66901983",
|
||||||
|
"url": "",
|
||||||
|
"community": "linuxphones@lemmy.ca",
|
||||||
|
"published": "2026-06-25T01:47:05.645866Z",
|
||||||
|
"excerpt": "This is my second linux phone and coming into it i had high hopes and low expectations, the result was a mixed bag but generally positive. My first was linux phone was the PinePhonePro and i really liked it at the time as a concept, but it was hard to use it as a daily driver, i…",
|
||||||
|
"media": [],
|
||||||
|
"score": 89,
|
||||||
|
"comments": 14
|
||||||
|
}
|
||||||
|
]
|
||||||
1
content/rates.json
Normal file
1
content/rates.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"date":"2026-08-04","micro_per_eur":{"USD":1151500,"JPY":181260000,"CZK":24200000,"DKK":7475400,"GBP":856390,"HUF":362300000,"PLN":4306300,"RON":5251000,"SEK":10992500,"CHF":931900,"ISK":142000000,"NOK":10992500,"TRY":54760600,"AUD":1637700,"BRL":5850300,"CAD":1619100,"CNY":7776700,"HKD":9031600,"IDR":20712150000,"ILS":3485000,"INR":109828500,"KRW":1643320000,"MXN":19907200,"MYR":4712500,"NZD":1956800,"PHP":70243000,"SGD":1476700,"THB":38385000,"ZAR":18931600}}
|
||||||
|
|
@ -1,21 +1,97 @@
|
||||||
# catcrafts.net Caddy site block
|
# catcrafts.net Caddy site block
|
||||||
#
|
#
|
||||||
# The WASM app (Crafter.Graphics) needs a cross-origin-isolated context
|
# Two upstreams: Caddy's file_server for build artifacts, and catcrafts-server
|
||||||
# (SharedArrayBuffer / threads), which requires these response headers. They
|
# for everything else. Point `root` at the host directory you bind-mount into
|
||||||
# are NOT optional — without them the page loads but the runtime fails.
|
# the runner as /deploy (the "-v /path/to/webroot:/deploy" in the runner's
|
||||||
|
# config.yaml).
|
||||||
#
|
#
|
||||||
# If you already have a `catcrafts.net { ... }` block, you only need to ADD the
|
# catcrafts-server speaks PLAINTEXT HTTP/1.1 on localhost — Caddy terminates
|
||||||
# three Cross-Origin-* header lines to it. This file is the complete block for
|
# TLS. That is also why the backend uses Crafter.Network's ListenerHTTP1 rather
|
||||||
# reference. Point `root` at the host directory you bind-mount into the runner
|
# than its HTTP/3 listener: Caddy cannot reverse_proxy to an h3 upstream.
|
||||||
# as /deploy (the "-v /path/to/webroot:/deploy" in the runner's config.yaml).
|
# Do not expose port 8081 directly.
|
||||||
|
|
||||||
catcrafts.net {
|
catcrafts.net {
|
||||||
root * /srv/catcrafts.net
|
root * /srv/catcrafts.net
|
||||||
|
|
||||||
header Cross-Origin-Opener-Policy "same-origin"
|
|
||||||
header Cross-Origin-Embedder-Policy "require-corp"
|
|
||||||
header Cross-Origin-Resource-Policy "same-origin"
|
|
||||||
|
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── cross-origin isolation, scoped ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The WASM runtime needs a cross-origin-isolated context (SharedArrayBuffer
|
||||||
|
# / threads), and these three headers are what provide it. They are NOT
|
||||||
|
# optional on a page that boots the module — without them it loads and the
|
||||||
|
# runtime fails.
|
||||||
|
#
|
||||||
|
# But they are scoped to the paths that actually load it, rather than applied
|
||||||
|
# site-wide. COEP: require-corp blocks every cross-origin subresource that
|
||||||
|
# does not opt in, so applying it to pages that have no wasm would constrain
|
||||||
|
# them for no benefit — and the shop's payment pages later must not inherit
|
||||||
|
# that restriction.
|
||||||
|
@isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \
|
||||||
|
/catcrafts-head.js /files.json /variants.json /*.wgsl
|
||||||
|
header @isolated {
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Embedder-Policy "require-corp"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
}
|
||||||
|
# Subresources an isolated document pulls in must carry CORP themselves.
|
||||||
|
header /styles.css Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
header /favicon.svg Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
|
||||||
|
# ── build artifacts: served from disk ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# file_server does sendfile, precompressed variants and range requests far
|
||||||
|
# better than the backend would. `precompressed` serves the .zst / .gz
|
||||||
|
# siblings the CI build produces, so the ~800 KB module is never recompressed
|
||||||
|
# per request. Cache-busted by the ?v=<buildId> in index.html.
|
||||||
|
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \
|
||||||
|
/catcrafts-head.js /files.json /variants.json /styles.css \
|
||||||
|
/favicon.svg /robots.txt /*.wgsl /*.jpg /posts.json /rates.json
|
||||||
|
handle @static {
|
||||||
|
header Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
file_server {
|
||||||
|
precompressed zstd gzip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── mirrored post media ──────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Deliberately NOT under the web root: that directory is mirrored with
|
||||||
|
# `rsync --delete` on every deploy, and this media is not always
|
||||||
|
# reproducible — if a source instance deletes a file, our copy is the only
|
||||||
|
# one left. Living on the app mount puts it physically outside the delete.
|
||||||
|
#
|
||||||
|
# Filenames are the content hash, so a changed file gets a new name and the
|
||||||
|
# immutable cache lifetime is honest.
|
||||||
|
handle_path /media/* {
|
||||||
|
root * /srv/catcrafts-app/media
|
||||||
|
header Cache-Control "public, max-age=31536000, immutable"
|
||||||
file_server
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── everything else: server-rendered ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own
|
||||||
|
# Cache-Control and returns real status codes — a 404 for an unknown path and
|
||||||
|
# a 301 for the retired /blog URLs, which a client-side router cannot do.
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:8081 {
|
||||||
|
health_uri /api/healthz
|
||||||
|
|
||||||
|
# If the backend is down, fall back to the static wasm shell so the
|
||||||
|
# site degrades to a client-rendered app rather than a Caddy 502.
|
||||||
|
# Content still renders; only real status codes and SSR are lost.
|
||||||
|
@down status 502 503 504
|
||||||
|
handle_response @down {
|
||||||
|
rewrite * /index.html
|
||||||
|
header Cache-Control "no-store"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
347
deploy/README.md
Normal file
347
deploy/README.md
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
# Deploying catcrafts.net
|
||||||
|
|
||||||
|
Two artifacts come out of CI and land in two different places, on purpose.
|
||||||
|
|
||||||
|
| | goes to | served by |
|
||||||
|
|---|---|---|
|
||||||
|
| wasm bundle + static assets | `/srv/catcrafts.net` | Caddy `file_server` |
|
||||||
|
| `catcrafts-server` + `content/` | `/srv/catcrafts-app` | itself, on `127.0.0.1:8081` |
|
||||||
|
|
||||||
|
They are kept apart because the web root is **publicly served and mirrored with
|
||||||
|
`rsync --delete`** on every push. Anything runtime-owned that lives there is
|
||||||
|
both published to the internet and destroyed on the next deploy — which matters
|
||||||
|
now for the content files and matters a great deal once the shop has a database
|
||||||
|
and bank credentials.
|
||||||
|
|
||||||
|
Runtime state goes in a third place, `/var/lib/catcrafts`, created by the
|
||||||
|
service's `StateDirectory=`. Today that is `orders.jsonl` (the order event log)
|
||||||
|
and `bunq-state.json` (the bunq session context, including the client RSA key
|
||||||
|
the server generates on first contact).
|
||||||
|
|
||||||
|
**Two things on this box cannot be regenerated.** Everything else — the wasm
|
||||||
|
bundle, the content, the binary — comes back from a rebuild.
|
||||||
|
|
||||||
|
The first is `orders.jsonl`. It is the ledger: every order and every status
|
||||||
|
transition, append-only, and the audit trail the tax records lean on. Back it
|
||||||
|
up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -d -m 0700 /var/backups/catcrafts
|
||||||
|
cp /var/lib/catcrafts/orders.jsonl \
|
||||||
|
/var/backups/catcrafts/orders-$(date -u +%F).jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
It contains names, addresses and email addresses, so it is personal data: keep
|
||||||
|
it 0600, keep it off the web root, and encrypt it before it leaves the machine.
|
||||||
|
(`bunq-state.json` is deliberately NOT worth backing up: delete it and the
|
||||||
|
server re-onboards from the API key on the next start.)
|
||||||
|
|
||||||
|
The second is `/srv/catcrafts-app/media` — the mirrored post images and screen
|
||||||
|
recordings. Usually reproducible from `content/posts.json`, but **not if a source
|
||||||
|
instance has deleted the file since**, and for these posts the media *is* the
|
||||||
|
content. It lives on the app mount rather than the web root specifically so
|
||||||
|
`rsync --delete` cannot reach it, and CI only ever adds to it. Worth including in
|
||||||
|
the same backup.
|
||||||
|
|
||||||
|
## Why the backend speaks plaintext HTTP/1.1
|
||||||
|
|
||||||
|
Caddy terminates TLS and reverse-proxies to loopback. The backend uses
|
||||||
|
Crafter.Network's `ListenerHTTP1` rather than its HTTP/3 `ListenerHTTP` for one
|
||||||
|
concrete reason: **Caddy cannot `reverse_proxy` to an h3 upstream.** Port 8081
|
||||||
|
must never be exposed directly.
|
||||||
|
|
||||||
|
## One-time host setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. service account, no login, no home
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin catcrafts
|
||||||
|
|
||||||
|
# 2. directories
|
||||||
|
mkdir -p /srv/catcrafts.net /srv/catcrafts-app
|
||||||
|
chown catcrafts:catcrafts /srv/catcrafts-app
|
||||||
|
# The web root stays writable by whatever uid the runner container uses.
|
||||||
|
|
||||||
|
# 3. units
|
||||||
|
cp deploy/catcrafts-server.service /etc/systemd/system/
|
||||||
|
cp deploy/catcrafts-deploy.service /etc/systemd/system/
|
||||||
|
cp deploy/catcrafts-deploy.path /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now catcrafts-deploy.path
|
||||||
|
# catcrafts-server is started by the first deploy; enable it so it survives a
|
||||||
|
# reboot:
|
||||||
|
systemctl enable catcrafts-server
|
||||||
|
|
||||||
|
# 4. Caddy — merge deploy/Caddyfile.example into your site config
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runner configuration
|
||||||
|
|
||||||
|
Both mounts are required. In the Forgejo runner's `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
container:
|
||||||
|
options: "-v /srv/catcrafts.net:/deploy -v /srv/catcrafts-app:/deploy-app"
|
||||||
|
```
|
||||||
|
|
||||||
|
The deploy steps fail with an explanatory message if either is missing, rather
|
||||||
|
than silently succeeding into the container's own filesystem.
|
||||||
|
|
||||||
|
## How the restart happens
|
||||||
|
|
||||||
|
CI runs **inside a container** and has no access to the host's systemd. Rather
|
||||||
|
than give the runner host root or a polkit rule — both of which grant far more
|
||||||
|
authority than "restart one service" needs — the last deploy step writes
|
||||||
|
`/srv/catcrafts-app/.deploy-stamp`, and `catcrafts-deploy.path` on the host
|
||||||
|
reacts.
|
||||||
|
|
||||||
|
`catcrafts-deploy.service` runs `catcrafts-server --selftest` as `ExecStartPre`
|
||||||
|
before restarting. That self-test exercises the HTML-escaping and JSON layers
|
||||||
|
that generate every byte of markup the site emits, so **a broken binary leaves
|
||||||
|
the working server running** instead of replacing it.
|
||||||
|
|
||||||
|
The binary is installed as `catcrafts-server.new` and `mv`d into place, so a
|
||||||
|
request arriving mid-copy never hits a truncated executable.
|
||||||
|
|
||||||
|
## If the backend is down
|
||||||
|
|
||||||
|
Caddy's `handle_response @down` falls back to serving the static `index.html`,
|
||||||
|
so the site degrades to the client-rendered wasm app rather than showing a 502.
|
||||||
|
Content still renders; what is lost is server-side rendering and real status
|
||||||
|
codes — an unknown path becomes a soft 404 again until the backend returns.
|
||||||
|
|
||||||
|
## Posts and their media
|
||||||
|
|
||||||
|
`content/posts-sources.json` holds the account and a **community allowlist**.
|
||||||
|
Only listed communities are fetched, so joining a new one does not silently
|
||||||
|
publish it to the site — add a line first.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/fetch-posts.sh # writes content/posts.json
|
||||||
|
tools/fetch-media.sh # mirrors the media, rewrites posts.json to /media/... paths
|
||||||
|
```
|
||||||
|
|
||||||
|
Order matters: the second reads what the first wrote. CI runs both before the
|
||||||
|
build. Neither fails the build on a network error — a fediverse outage leaves the
|
||||||
|
previous `posts.json` in place, and a single failed download leaves that one entry
|
||||||
|
pointing at its original URL rather than losing the post.
|
||||||
|
|
||||||
|
**`fetch-media.sh` wants `ffprobe`** (Arch: `ffmpeg`) to read pixel dimensions,
|
||||||
|
which become the `width`/`height` attributes that stop the page reflowing as
|
||||||
|
several 5 MB recordings arrive. It degrades to no dimensions without it —
|
||||||
|
so `tools/e2e.sh` asserts they are present, and a build host missing the package
|
||||||
|
fails at the e2e gate instead of quietly shipping a janky page.
|
||||||
|
|
||||||
|
**Only the post's main media is used.** Lemmy distinguishes the media a post *is*
|
||||||
|
(`post.url`, with `post.thumbnail_url` as its generated still) from images merely
|
||||||
|
embedded in the body. Only the former is mirrored — a post whose pictures are
|
||||||
|
body-only renders as text, which is correct. Videos get the thumbnail as their
|
||||||
|
`poster`; without it a `preload="metadata"` video is a black box until someone
|
||||||
|
presses play.
|
||||||
|
|
||||||
|
**Thread links point at the community's instance, not the author's.** A post's
|
||||||
|
`ap_id` is on the account's own instance, but the discussion is in the community,
|
||||||
|
which is federated elsewhere and has its own local id for the same thread. So
|
||||||
|
`fetch-posts.sh` resolves each `ap_id` through the community instance's
|
||||||
|
`resolve_object` and stores that URL. One request per post, at build time; a
|
||||||
|
failure falls back to the `ap_id`, which still reaches a readable copy.
|
||||||
|
|
||||||
|
Media is **mirrored, not hotlinked**. Three reasons: the privacy notice says
|
||||||
|
everything the browser loads comes from catcrafts.net and should stay true;
|
||||||
|
hotlinking would send every visitor's IP to the source instance; and these posts
|
||||||
|
are their media, so a deleted upstream file would gut the page. Filenames are the
|
||||||
|
content hash, which is why the cache lifetime can be a year.
|
||||||
|
|
||||||
|
## Payments: Mollie setup
|
||||||
|
|
||||||
|
The rail is Mollie (bunq.me was measured and disqualified: €500/transaction on
|
||||||
|
cards and no method at all for a non-EU buyer at phone prices — it is a P2P
|
||||||
|
tool; the bunq client remains in the tree, unused, in case an account sweep is
|
||||||
|
ever wanted). The server needs exactly one secret: the Mollie API key.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Keys live in the Mollie dashboard: Developers -> API keys. A test_… key
|
||||||
|
# works against the real API from the moment the account exists — verify the
|
||||||
|
# whole flow with it BEFORE swapping in the live_… key.
|
||||||
|
install -d -m 0755 /etc/catcrafts
|
||||||
|
cat > /etc/catcrafts/payments.env <<'ENV'
|
||||||
|
MOLLIE_API_KEY=test_your-key-here
|
||||||
|
ENV
|
||||||
|
chmod 0600 /etc/catcrafts/payments.env
|
||||||
|
systemctl restart catcrafts-server
|
||||||
|
journalctl -u catcrafts-server | tail # should say "payments: mollie"
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the env file the server starts with payments off: the whole site works,
|
||||||
|
the product page renders, and checkout answers 503 with an honest message —
|
||||||
|
degraded, not down.
|
||||||
|
|
||||||
|
Mechanics worth knowing:
|
||||||
|
|
||||||
|
* The reconciler polls each open order (`GET /v2/payments/{id}`) every 10 s
|
||||||
|
while fresh, backing off with age. `?redirect` back from Mollie is ignored
|
||||||
|
by design — only the authenticated poll moves an order to paid, and the
|
||||||
|
paid event records the method (`ideal`, `creditcard`, …) in the ledger.
|
||||||
|
* Mollie payments EXPIRE. A payment that reaches canceled/expired/failed
|
||||||
|
lapses the order automatically — the buyer just orders again.
|
||||||
|
* Card money stays disputable for months even after "paid": before shipping a
|
||||||
|
large or exported order, glance at the `via` column in `--orders`. iDEAL
|
||||||
|
and bank transfers are final; `creditcard` is the one with a tail.
|
||||||
|
* Mollie onboarding reviews the shop: the imprint (KVK, contact address),
|
||||||
|
terms and privacy pages must be real before they approve live payments.
|
||||||
|
They are — and e2e now fails the build if a PLACEHOLDER marker ever
|
||||||
|
reaches a rendered page again.
|
||||||
|
|
||||||
|
## Shipping rates: Sendcloud (optional)
|
||||||
|
|
||||||
|
Without configuration, shipping is priced by the three-zone table in
|
||||||
|
the compiled-in product data (NL / EU / world, Catcrafts.Shared-Content.cppm) — honest flat rates you set. With a
|
||||||
|
Sendcloud account, the server fetches the real per-country prices of one
|
||||||
|
shipping method daily and uses those instead, falling back to the zones for
|
||||||
|
any country the method does not cover:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# credentials from Sendcloud: Settings -> Integrations -> API
|
||||||
|
cat >> /etc/catcrafts/bunq.env <<'ENV'
|
||||||
|
SENDCLOUD_PUBLIC_KEY=...
|
||||||
|
SENDCLOUD_SECRET_KEY=...
|
||||||
|
SENDCLOUD_METHOD='PostNL Parcels non-EU,DPD Home'
|
||||||
|
ENV
|
||||||
|
systemctl restart catcrafts-server
|
||||||
|
journalctl -u catcrafts-server | grep shipping: # "table refreshed (N countries...)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`SENDCLOUD_METHOD` is a comma-separated list of name substrings, merged in
|
||||||
|
order with the FIRST match per country winning — put the postal method
|
||||||
|
first so non-EU destinations get post rates (a courier method that also
|
||||||
|
covers Norway or Switzerland would otherwise price them at courier rates,
|
||||||
|
€54 instead of €19), and the courier second to fill the EU. The fetched table is
|
||||||
|
cached next to the orders file so a restart during a Sendcloud outage keeps
|
||||||
|
the last known prices. Like the bunq client, this integration is UNTESTED
|
||||||
|
against the live API until credentials exist — the response parser is covered
|
||||||
|
by --selftest, the fetch around it is thin.
|
||||||
|
|
||||||
|
The buyer sees whatever the server will charge: the checkout page embeds the
|
||||||
|
active table into its live total, and the amount is computed server-side at
|
||||||
|
order time from the same data.
|
||||||
|
|
||||||
|
## Invoice signing (GPG)
|
||||||
|
|
||||||
|
Paid orders offer a clearsigned markdown invoice at `/order/<token>/invoice.md`.
|
||||||
|
Numbering continues the pre-shop administration: one series per customer — a
|
||||||
|
random UUID as the customer number, invoices counting sequentially within it
|
||||||
|
(`f57c6512-…-3`), keyed by the buyer's email. Art. 226(2) permits "one or more
|
||||||
|
series"; completeness is provable by reconciling the append-only ledger against
|
||||||
|
the payment provider's records. The signature makes the invoice verifiable
|
||||||
|
forever, independent of this server — which is why the order page tells buyers
|
||||||
|
to download it rather than promising to host receipts indefinitely.
|
||||||
|
|
||||||
|
One-time key setup on the server, as the service user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
|
||||||
|
gpg --batch --passphrase '' --quick-gen-key 'Catcrafts invoices <invoices@catcrafts.net>' default default never
|
||||||
|
# export the PUBLIC key and commit it to the repo so buyers can verify:
|
||||||
|
sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
|
||||||
|
gpg --armor --export invoices@catcrafts.net > invoice-key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `/etc/catcrafts/payments.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
INVOICE_GPG_KEY=invoices@catcrafts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
and `Environment=GNUPGHOME=/var/lib/catcrafts/gnupg` in the service unit (see
|
||||||
|
catcrafts-server.service). The key has no passphrase because the service signs
|
||||||
|
unattended; the keyring lives in the 0700 StateDirectory. With a key configured,
|
||||||
|
a signing failure is a 500 — an unsigned invoice is never served by accident.
|
||||||
|
Without one (dev), invoices carry a visible UNSIGNED marker.
|
||||||
|
|
||||||
|
## Reading the orders ledger
|
||||||
|
|
||||||
|
```sh
|
||||||
|
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
orders: 2
|
||||||
|
|
||||||
|
reference status total cc created token
|
||||||
|
CC-3F9A2C paid 595.00 NL 2026-08-04T14:02:11Z 3f9a2c…
|
||||||
|
CC-91B04D awaiting_payment 534.34 CA 2026-08-04T15:40:03Z 91b04d…
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual transitions exist for the cases automation cannot see — a payment bunq
|
||||||
|
confirmed out-of-band, the parcel handed to the carrier, a refund:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-paid <token>
|
||||||
|
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-shipped <token>
|
||||||
|
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --cancel <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each appends a status event to the log — nothing is ever rewritten, so the
|
||||||
|
file remains its own audit trail. Deleting personal data on request is an edit
|
||||||
|
of the fields the seven-year fiscal retention does not cover.
|
||||||
|
|
||||||
|
## Verifying a deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status catcrafts-server
|
||||||
|
curl -s localhost:8081/api/healthz # ok + content counts
|
||||||
|
|
||||||
|
# real status codes, which a client-side router cannot produce
|
||||||
|
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/nope # 404
|
||||||
|
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/blog # 301
|
||||||
|
|
||||||
|
# the SEO check: content present with no JavaScript involved
|
||||||
|
curl -s https://catcrafts.net/projects | grep -c '<script' # 0
|
||||||
|
curl -s https://catcrafts.net/projects | grep -o '<title>[^<]*'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running it locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/dev.sh # build both products and serve on :8080
|
||||||
|
tools/dev.sh --no-build # reuse what is already in bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole site: Caddy in front, the backend behind, static assets from
|
||||||
|
disk, cross-origin headers scoped exactly as in production. Ctrl-C stops both.
|
||||||
|
Orders from the session go to a temp file and are discarded on exit; the
|
||||||
|
fake payment rail is active — `touch <workdir>/orders.jsonl.fake-paid` plays
|
||||||
|
the part of the customer paying.
|
||||||
|
|
||||||
|
**Do not run `catcrafts-server --serve` alone and expect a working site.** It
|
||||||
|
serves pages only — static assets are Caddy's job — so `/styles.css` 404s and
|
||||||
|
every page renders unstyled. That looks broken but isn't; it is the deployment
|
||||||
|
split working as designed.
|
||||||
|
|
||||||
|
### Other useful commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/fetch-posts.sh # pull posts from the allowed communities
|
||||||
|
tools/fetch-media.sh [dir] # mirror their media locally (run after the above)
|
||||||
|
tools/fetch-rates.sh # ECB reference rates for the indicative prices
|
||||||
|
tools/e2e.sh # 143 HTTP checks against a real server; the CI gate
|
||||||
|
crafter-build --local -r # the wasm app alone, no backend, on :8080
|
||||||
|
|
||||||
|
<server>/catcrafts-server --selftest # ~140 in-process assertions
|
||||||
|
<server>/catcrafts-server --routes # status + title for every route
|
||||||
|
<server>/catcrafts-server --render /projects # dump one page's HTML
|
||||||
|
<server>/catcrafts-server --orders FILE # the orders ledger + manual transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### If a `bin/` glob matches two directories
|
||||||
|
|
||||||
|
The variant directory name embeds a config hash, and **`--local` and
|
||||||
|
non-`--local` builds hash differently**: `--local` resolves the Crafter libraries
|
||||||
|
from sibling working trees, a plain build fetches them from Forgejo. They are
|
||||||
|
genuinely different configurations and both land in `bin/`.
|
||||||
|
|
||||||
|
Mixing them leaves two directories, and any script globbing for one picks
|
||||||
|
arbitrarily — which in practice means testing a stale binary and believing the
|
||||||
|
result. Every script here refuses to guess and tells you to `rm -rf bin`. Pick
|
||||||
|
one mode and stay in it.
|
||||||
28
deploy/catcrafts-deploy.path
Normal file
28
deploy/catcrafts-deploy.path
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Watches for a deploy marker and restarts the backend.
|
||||||
|
#
|
||||||
|
# Why this exists: the Forgejo runner executes inside a container. It can write
|
||||||
|
# to the bind-mounted deploy directories but it cannot talk to the host's
|
||||||
|
# systemd, and the alternatives are worse — handing the runner host root, or a
|
||||||
|
# polkit rule, both of which give CI far more authority than "restart one
|
||||||
|
# service" needs.
|
||||||
|
#
|
||||||
|
# So CI touches /srv/catcrafts-app/.deploy-stamp as its last step, and the host
|
||||||
|
# reacts. The runner never gains any privilege it did not already have, and the
|
||||||
|
# restart policy stays entirely on the host side where it belongs.
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# cp catcrafts-deploy.{path,service} /etc/systemd/system/
|
||||||
|
# systemctl daemon-reload && systemctl enable --now catcrafts-deploy.path
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Watch for a catcrafts.net deploy marker
|
||||||
|
Documentation=https://forgejo.catcrafts.net/Catcrafts/catcrafts.net
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
# PathModified rather than PathChanged: `touch` on an existing file only updates
|
||||||
|
# mtime, which PathChanged does not consider a change.
|
||||||
|
PathModified=/srv/catcrafts-app/.deploy-stamp
|
||||||
|
Unit=catcrafts-deploy.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
20
deploy/catcrafts-deploy.service
Normal file
20
deploy/catcrafts-deploy.service
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Triggered by catcrafts-deploy.path when CI touches the deploy marker.
|
||||||
|
#
|
||||||
|
# Deliberately does exactly one thing. It is reachable from anything that can
|
||||||
|
# write the marker file, so it must not be a general-purpose hook — no scripts
|
||||||
|
# from the deployed tree, no arguments derived from the marker's contents.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Restart catcrafts.net backend after a deploy
|
||||||
|
Documentation=https://forgejo.catcrafts.net/Catcrafts/catcrafts.net
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
|
||||||
|
# Verify the freshly deployed binary before cutting over. --selftest exercises
|
||||||
|
# the escaping and JSON layers that produce every byte of markup the site
|
||||||
|
# emits; if it fails, the currently-running (working) server is left alone
|
||||||
|
# rather than replaced with a broken one.
|
||||||
|
ExecStartPre=/srv/catcrafts-app/catcrafts-server --selftest
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/systemctl restart catcrafts-server.service
|
||||||
91
deploy/catcrafts-server.service
Normal file
91
deploy/catcrafts-server.service
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# catcrafts-server — the server-rendering backend.
|
||||||
|
#
|
||||||
|
# Install to /etc/systemd/system/catcrafts-server.service, then:
|
||||||
|
# systemctl daemon-reload && systemctl enable --now catcrafts-server
|
||||||
|
#
|
||||||
|
# Layout this expects on the host:
|
||||||
|
# /srv/catcrafts.net/ the wasm bundle + static assets (Caddy's root,
|
||||||
|
# and the rsync --delete target from CI)
|
||||||
|
# /srv/catcrafts-app/ the server binary and content/, deployed by CI
|
||||||
|
# catcrafts-server
|
||||||
|
# content/{projects,posts}.json
|
||||||
|
# /var/lib/catcrafts/ runtime state — the SQLite database and keys
|
||||||
|
# once the shop exists. NEVER in the webroot:
|
||||||
|
# that directory is both publicly served and
|
||||||
|
# wiped by `rsync --delete` on every deploy.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=catcrafts.net server-rendering backend
|
||||||
|
Documentation=https://forgejo.catcrafts.net/Catcrafts/catcrafts.net
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Caddy proxies to this; if it is down Caddy falls back to the static shell, so
|
||||||
|
# there is no hard ordering requirement between them.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=catcrafts
|
||||||
|
Group=catcrafts
|
||||||
|
|
||||||
|
WorkingDirectory=/srv/catcrafts-app
|
||||||
|
# --webroot points at Caddy's root so the boot <script> tags (with their
|
||||||
|
# per-build ?v= cache buster) are read from the deployed index.html rather than
|
||||||
|
# hardcoded. Bind to loopback only: Caddy terminates TLS and this speaks
|
||||||
|
# plaintext HTTP/1.1.
|
||||||
|
ExecStart=/srv/catcrafts-app/catcrafts-server --serve 8081 \
|
||||||
|
--content=/srv/catcrafts-app/content \
|
||||||
|
--webroot=/srv/catcrafts.net \
|
||||||
|
--orders=/var/lib/catcrafts/orders.jsonl \
|
||||||
|
--bunq-state=/var/lib/catcrafts/bunq-state.json
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2s
|
||||||
|
|
||||||
|
# ── hardening ────────────────────────────────────────────────────────────
|
||||||
|
# This process will later hold bank and payment credentials, so it gets locked
|
||||||
|
# down now rather than after there is something worth stealing.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
# Only IP sockets — no unix, no netlink, no packet sockets.
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
|
||||||
|
# ProtectSystem=strict makes everything read-only; grant just the state
|
||||||
|
# directory. StateDirectory creates /var/lib/catcrafts with the right owner.
|
||||||
|
StateDirectory=catcrafts
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
# The content and webroot are read-only to this process by design: content is
|
||||||
|
# generated at build time and the webroot belongs to the deploy step.
|
||||||
|
ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
|
||||||
|
|
||||||
|
# Secrets arrive from OUTSIDE the deployed tree — the web root is public and
|
||||||
|
# rsync-wiped, and /srv/catcrafts-app is CI-writable; neither may ever hold a
|
||||||
|
# credential. /etc/catcrafts/payments.env (root:root 0600) carries:
|
||||||
|
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail
|
||||||
|
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional,
|
||||||
|
# live shipping rates; zone table without them
|
||||||
|
# BUNQ_API_KEY=... legacy: only used when no Mollie key is set
|
||||||
|
# The '-' prefix makes the file optional: without it the server starts with
|
||||||
|
# payments off and the shop renders but refuses checkout — degraded, not down.
|
||||||
|
EnvironmentFile=-/etc/catcrafts/payments.env
|
||||||
|
EnvironmentFile=-/etc/catcrafts/bunq.env
|
||||||
|
# Invoice signing keyring (see deploy/README.md, "Invoice signing").
|
||||||
|
Environment=GNUPGHOME=/var/lib/catcrafts/gnupg
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
35
favicon.svg
35
favicon.svg
|
|
@ -1,6 +1,31 @@
|
||||||
<!-- save this as emoji-favicon.svg -->
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Catcrafts">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">
|
<!--
|
||||||
<text x="50%" y="50%" font-size="110" text-anchor="middle" dominant-baseline="central">
|
The ASCII cat, reified: ^ ^ caret eyes and the site's ">_" prompt as the
|
||||||
🐱
|
mouth — the cat speaks shell. The prompt sits left of centre on purpose;
|
||||||
</text>
|
prompts are left-aligned. Replaces the bare ">_" tile, which was honest
|
||||||
|
about the terminal but silent about the name.
|
||||||
|
|
||||||
|
Drawn as paths, not <text>. A text favicon depends on a font being available
|
||||||
|
at 16px in whatever context the browser rasterises it — including contexts
|
||||||
|
with no font stack at all — and falls back to a blank tile when it is not.
|
||||||
|
Paths always render.
|
||||||
|
|
||||||
|
The same geometry is inlined in RenderNav (Catcrafts.Shared-Views.cppm)
|
||||||
|
with CSS-variable colours. Change the drawing there too or the header and
|
||||||
|
the tab icon drift apart.
|
||||||
|
-->
|
||||||
|
<rect width="64" height="64" rx="12" fill="#0b0d10"/>
|
||||||
|
<!-- head: flat crown between two ears, rounded jaw -->
|
||||||
|
<path d="M9 22 L13 7 L25 15 L39 15 L51 7 L55 22 L55 44 Q55 56 43 56 L21 56 Q9 56 9 44 Z"
|
||||||
|
fill="none" stroke="#4cc2ff" stroke-width="5" stroke-linejoin="round"/>
|
||||||
|
<!-- eyes: the ^ ^ of a happy ASCII cat -->
|
||||||
|
<path d="M17 34 L23 27 L29 34 M35 34 L41 27 L47 34"
|
||||||
|
fill="none" stroke="#4cc2ff" stroke-width="5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- mouth: the shell prompt -->
|
||||||
|
<path d="M23 40 L29 45 L23 50"
|
||||||
|
fill="none" stroke="#e6e8ec" stroke-width="5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M34 50 L42 50"
|
||||||
|
fill="none" stroke="#e6e8ec" stroke-width="5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 222 B After Width: | Height: | Size: 1.6 KiB |
BIN
images/catcrafts-logo.png
Normal file
BIN
images/catcrafts-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
images/fp6-pmos.jpg
Normal file
BIN
images/fp6-pmos.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
|
|
@ -1,112 +0,0 @@
|
||||||
/*
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export module Catcrafts:Blog_impl;
|
|
||||||
import :Blog;
|
|
||||||
import :Root;
|
|
||||||
import :Views;
|
|
||||||
import :Demo;
|
|
||||||
import Crafter.Graphics;
|
|
||||||
import std;
|
|
||||||
|
|
||||||
using namespace Crafter;
|
|
||||||
|
|
||||||
namespace Catcrafts {
|
|
||||||
// Post that hosts the live ray-traced WebGPU demo. RenderBlogPost
|
|
||||||
// injects the mount container into this post and mounts the render
|
|
||||||
// canvas into it once the DOM is in place.
|
|
||||||
constexpr std::string_view kDemoPostSlug = "hello-world-2";
|
|
||||||
|
|
||||||
// Markup for the embedded demo: a fixed-height host box the render
|
|
||||||
// canvas is reparented into (see Catcrafts:Demo / WebGPU::SetCanvasMount)
|
|
||||||
// plus a caption. The id must match kDemoMountId.
|
|
||||||
constexpr std::string_view kDemoCardHtml = R"(
|
|
||||||
<div class="webgpu-demo">
|
|
||||||
<div id="webgpu-demo" class="webgpu-demo-canvas"></div>
|
|
||||||
<p class="webgpu-demo-caption">
|
|
||||||
Live above: a scene ray-traced in real time — four coloured
|
|
||||||
point lights, one soft shadow per light — running through the
|
|
||||||
Crafter.Graphics WebGPU wavefront tracer, driven from this page's
|
|
||||||
C++ compiled to WebAssembly. Not a video, not an iframe: the same
|
|
||||||
WASM module that rendered this text is tracing those pixels.
|
|
||||||
</p>
|
|
||||||
</div>)";
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Catcrafts {
|
|
||||||
// Persistent storage for the per-post card click handlers. HtmlElementPtr
|
|
||||||
// unregisters its listeners on destruction, so the elements have to
|
|
||||||
// outlive Render(); clear() at the top of RenderBlog() detaches the
|
|
||||||
// previous render's handlers before we attach the new ones.
|
|
||||||
std::vector<Dom::HtmlElementPtr> blogButtons;
|
|
||||||
|
|
||||||
void RenderBlog() {
|
|
||||||
blogButtons.clear();
|
|
||||||
std::string html = "";
|
|
||||||
for(const BlogPost& post : posts) {
|
|
||||||
std::string previewContent = post.content;
|
|
||||||
if(previewContent.length() > 200) {
|
|
||||||
std::size_t lastSpace = previewContent.find_last_of(' ', 200);
|
|
||||||
if(lastSpace != std::string::npos) {
|
|
||||||
previewContent = previewContent.substr(0, lastSpace) + "...";
|
|
||||||
} else {
|
|
||||||
previewContent = previewContent.substr(0, 200) + "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html += std::format(R"(
|
|
||||||
<div class="post fade-in" id="blog-post-{}">
|
|
||||||
<div class="post-header">
|
|
||||||
<h2 class="post-title"><a>{}</a></h2>
|
|
||||||
<span class="post-date">{}</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-content">
|
|
||||||
{}
|
|
||||||
</div>
|
|
||||||
<div class="post-footer">
|
|
||||||
<a class="btn">Read Full Post</a>
|
|
||||||
</div>
|
|
||||||
</div>)", post.slug, post.name, post.date, previewContent);
|
|
||||||
}
|
|
||||||
MainContent().SetInnerHTML(std::format(R"(<div class="blog-posts">{}</div>)", html));
|
|
||||||
|
|
||||||
for(const BlogPost& post : posts) {
|
|
||||||
Dom::HtmlElementPtr& cardView = blogButtons.emplace_back(std::format("blog-post-{}", post.slug));
|
|
||||||
cardView.AddClickListener([slug = post.slug](Dom::MouseEvent) {
|
|
||||||
Router::PushState("{}", "", std::format("/blog/{}", slug));
|
|
||||||
RenderRoot(std::format("/blog/{}", slug));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void RenderBlogPost(const std::string_view slug) {
|
|
||||||
for(const BlogPost& post : posts) {
|
|
||||||
if(post.slug == slug) {
|
|
||||||
const bool isDemo = post.slug == kDemoPostSlug;
|
|
||||||
MainContent().SetInnerHTML(std::format(R"(
|
|
||||||
<div class="blog-post-page">
|
|
||||||
<div class="post-header">
|
|
||||||
<h1 class="post-title">{}</h1>
|
|
||||||
<span class="post-date">{}</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-content">
|
|
||||||
{}
|
|
||||||
</div>
|
|
||||||
{}
|
|
||||||
</div>)", post.name, post.date, post.content, isDemo ? kDemoCardHtml : std::string_view{}));
|
|
||||||
|
|
||||||
// The #webgpu-demo container now exists in the DOM, so the
|
|
||||||
// render canvas can be reparented into it and tracing can
|
|
||||||
// start. RenderRoot() already called UnmountDemo() for us.
|
|
||||||
if(isDemo) MountDemo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MainContent().SetInnerHTML("<h1>Post Not Found</h1><p>The requested blog post could not be found.</p>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,34 +9,189 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
||||||
export module Catcrafts:Root_impl;
|
export module Catcrafts:Root_impl;
|
||||||
import :Root;
|
import :Root;
|
||||||
import :Views;
|
import :Views;
|
||||||
import :Blog;
|
|
||||||
import :Demo;
|
import :Demo;
|
||||||
import Crafter.Graphics;
|
import Crafter.Graphics;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
namespace Catcrafts {
|
namespace Catcrafts {
|
||||||
void RenderRoot(const std::string_view route) {
|
namespace {
|
||||||
// Every route change replaces <main>'s innerHTML, which would
|
// Every element this render attached a listener to.
|
||||||
// orphan the demo canvas if it's currently mounted inside a post.
|
//
|
||||||
// Detach + hide it first; the post renderer re-mounts if needed.
|
// HtmlElementPtr's destructor unregisters its handlers on both the C++
|
||||||
|
// and the JS side, so this vector IS the binding lifetime — clearing it
|
||||||
|
// is the unbind step. It must be cleared BEFORE the innerHTML that owns
|
||||||
|
// those elements is replaced: afterwards the handles point at detached
|
||||||
|
// nodes, and their destructors call removeEventListener on orphans,
|
||||||
|
// leaking a cookie in the JS handle table on every navigation.
|
||||||
|
std::vector<Dom::HtmlElementPtr> routeBindings;
|
||||||
|
std::vector<std::string> linkTargets;
|
||||||
|
|
||||||
|
bool demoMounted = false;
|
||||||
|
|
||||||
|
// Assign an id to every in-site link so getElementById can find it. The
|
||||||
|
// Dom API has no querySelector, so anchors cannot be enumerated — either
|
||||||
|
// the renderer emits ids or the app injects them.
|
||||||
|
//
|
||||||
|
// This only runs on the client-rendered fallback path (see below), where
|
||||||
|
// this module produced the markup a moment earlier and knows its shape.
|
||||||
|
// On a server-rendered page nothing here runs at all.
|
||||||
|
std::string TagLinks(std::string_view html, std::vector<std::string>& outTargets) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(html.size() + 64);
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < html.size()) {
|
||||||
|
const std::size_t open = html.find("<a ", i);
|
||||||
|
if (open == std::string_view::npos) {
|
||||||
|
out.append(html.substr(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.append(html.substr(i, open - i));
|
||||||
|
const std::size_t close = html.find('>', open);
|
||||||
|
if (close == std::string_view::npos) {
|
||||||
|
out.append(html.substr(open));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::string_view tag = html.substr(open, close - open + 1);
|
||||||
|
|
||||||
|
// Only same-origin paths are intercepted. External links,
|
||||||
|
// mailto: and fragments keep their default behaviour —
|
||||||
|
// hijacking those would break opening in a new tab and jumping
|
||||||
|
// to an anchor.
|
||||||
|
bool internal = false;
|
||||||
|
std::string target;
|
||||||
|
if (const std::size_t hrefPos = tag.find("href=\""); hrefPos != std::string_view::npos) {
|
||||||
|
const std::size_t vs = hrefPos + 6;
|
||||||
|
if (const std::size_t ve = tag.find('"', vs); ve != std::string_view::npos) {
|
||||||
|
const std::string_view href = tag.substr(vs, ve - vs);
|
||||||
|
if (!href.empty() && href[0] == '/'
|
||||||
|
&& !(href.size() > 1 && href[1] == '/')) {
|
||||||
|
internal = true;
|
||||||
|
target = std::string(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internal) {
|
||||||
|
out.append("<a id=\"");
|
||||||
|
out.append(std::format("cc-link-{}", outTargets.size()));
|
||||||
|
out.append("\"");
|
||||||
|
out.append(tag.substr(2));
|
||||||
|
outTargets.push_back(std::move(target));
|
||||||
|
} else {
|
||||||
|
out.append(tag);
|
||||||
|
}
|
||||||
|
i = close + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the renderer only for the demo it actually implements.
|
||||||
|
//
|
||||||
|
// The compiled renderer IS the ray tracer — its scene, pipeline and
|
||||||
|
// shaders are specific to it — so it can only serve the demo whose
|
||||||
|
// mount element it knows. Checking the content entry's mountId against
|
||||||
|
// kDemoMountId means a second demo added to the compiled catalogue
|
||||||
|
// (Catcrafts.Shared:Content) gets its page and
|
||||||
|
// its card without silently hijacking this canvas, and a typo'd mountId
|
||||||
|
// shows up as a missing render rather than a mismatch nobody notices.
|
||||||
|
void MountDemoIfPresent(const Route& route);
|
||||||
|
|
||||||
|
void BindLinks() {
|
||||||
|
for (std::size_t k = 0; k < linkTargets.size(); ++k) {
|
||||||
|
Dom::HtmlElementPtr link(std::format("cc-link-{}", k));
|
||||||
|
if (link.ptr == 0) continue;
|
||||||
|
const std::string target = linkTargets[k];
|
||||||
|
// preventDefault = true is what makes this possible at all:
|
||||||
|
// without it the handler runs AND the browser performs a full
|
||||||
|
// page load. The links stay honest <a href> elements, so
|
||||||
|
// crawlers, middle-click and "copy link" all still work.
|
||||||
|
link.AddClickListener([target](Dom::MouseEvent ev) {
|
||||||
|
// Modified clicks and non-primary buttons keep their default
|
||||||
|
// behaviour, so "open in new tab" survives.
|
||||||
|
if (ev.button != 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) {
|
||||||
|
Router::Navigate(target, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NavigateTo(target);
|
||||||
|
}, true);
|
||||||
|
routeBindings.push_back(std::move(link));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void MountDemoIfPresent(const Route& route) {
|
||||||
|
if (route.kind != RouteKind::Demo) return;
|
||||||
|
const Demo* d = SiteData().FindDemo(route.slug);
|
||||||
|
if (!d || !d->needsWasm || d->mountId != kDemoMountId) return;
|
||||||
|
MountDemo();
|
||||||
|
demoMounted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RenderCurrentRoute() {
|
||||||
|
const Route route = ParseRoute(Router::GetPath(), Router::GetSearch());
|
||||||
|
|
||||||
|
if (demoMounted) {
|
||||||
UnmountDemo();
|
UnmountDemo();
|
||||||
|
demoMounted = false;
|
||||||
std::string currentRoute = std::string(route);
|
|
||||||
|
|
||||||
if(currentRoute == "/blog" || currentRoute == "/") {
|
|
||||||
RenderBlog();
|
|
||||||
} else if(currentRoute.rfind("/blog/", 0) == 0) {
|
|
||||||
std::size_t pos = currentRoute.find_last_of('/');
|
|
||||||
if(pos != std::string::npos) {
|
|
||||||
std::string postSlug = currentRoute.substr(pos + 1);
|
|
||||||
RenderBlogPost(postSlug);
|
|
||||||
} else {
|
|
||||||
MainContent().SetInnerHTML("<h1>Post Not Found</h1><p>The requested blog post could not be found.</p>");
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
RenderBlog();
|
// On a server-rendered page the DOM is already correct for this URL, so
|
||||||
|
// the only thing left to do is mount the renderer if this is /demo.
|
||||||
|
//
|
||||||
|
// No re-render and no link interception. Re-rendering would replace
|
||||||
|
// correct markup with identical markup and flash; intercepting links
|
||||||
|
// would buy nothing, because every other route is served fully rendered
|
||||||
|
// and a normal navigation is already fast. The wasm module exists on
|
||||||
|
// this page for the ray tracer, not to be a router.
|
||||||
|
if (AdoptedSsr()) {
|
||||||
|
MountDemoIfPresent(route);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── client-rendered fallback ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only reached when the document came from the static shell — i.e. the
|
||||||
|
// backend was down and Caddy served the wasm bundle's empty-bodied
|
||||||
|
// index.html. Here the app is the whole site.
|
||||||
|
if (!route.canonicalRedirect.empty()) {
|
||||||
|
// Rewrite the address bar to the canonical path without adding a
|
||||||
|
// history entry, so Back does not bounce between the old URL and
|
||||||
|
// the new one.
|
||||||
|
Router::ReplaceState("{}", "", route.canonicalRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbind before the DOM those listeners point at is destroyed.
|
||||||
|
routeBindings.clear();
|
||||||
|
linkTargets.clear();
|
||||||
|
|
||||||
|
const Views::RenderedPage page = Views::RenderRoute(route, SiteData());
|
||||||
|
MainContent().SetInnerHTML(TagLinks(page.main.View(), linkTargets));
|
||||||
|
|
||||||
|
// Re-render the nav so the active marker follows the route, tagged with
|
||||||
|
// the SAME accumulator so nav ids continue the sequence instead of
|
||||||
|
// restarting at cc-link-0 and colliding with the content links —
|
||||||
|
// getElementById would then return whichever appeared first in the
|
||||||
|
// document and half the links would navigate to the wrong place.
|
||||||
|
const RouteKind navKind =
|
||||||
|
route.kind == RouteKind::LegacyBlog ? RouteKind::Posts : route.kind;
|
||||||
|
Dom::HtmlElementPtr header("cc-header");
|
||||||
|
if (header.ptr != 0) {
|
||||||
|
header.SetInnerHTML(TagLinks(Views::RenderNav(navKind).View(), linkTargets));
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDocumentTitle(page.meta.title);
|
||||||
|
BindLinks();
|
||||||
|
|
||||||
|
MountDemoIfPresent(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigateTo(std::string_view path) {
|
||||||
|
Router::PushState("{}", "", path);
|
||||||
|
RenderCurrentRoute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,43 +9,96 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
||||||
export module Catcrafts:Views_impl;
|
export module Catcrafts:Views_impl;
|
||||||
import :Views;
|
import :Views;
|
||||||
import Crafter.Graphics;
|
import Crafter.Graphics;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
namespace Catcrafts {
|
namespace Catcrafts {
|
||||||
namespace {
|
namespace {
|
||||||
// Owning handle for the page-chrome root. `std::optional` lets us
|
// Owning handle for the page chrome, ONLY when this module created it.
|
||||||
// defer construction until InitializePage() runs (the CreateInBody
|
// std::optional defers construction until InitializePage() runs — a
|
||||||
// call would otherwise fire at static-init time, before main()).
|
// CreateInBody at static-init time would fire before main().
|
||||||
|
//
|
||||||
|
// Left empty when the server already rendered the chrome: the handle is
|
||||||
|
// owning, and destroying it would remove the server's markup from the
|
||||||
|
// document.
|
||||||
std::optional<Dom::HtmlElement> root;
|
std::optional<Dom::HtmlElement> root;
|
||||||
|
|
||||||
|
// True when the document arrived server-rendered.
|
||||||
|
bool adoptedSsr = false;
|
||||||
|
|
||||||
|
Views::SiteContent content;
|
||||||
|
bool contentLoaded = false;
|
||||||
|
|
||||||
|
Window* activeWindow = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetActiveWindow(Window* window) { activeWindow = window; }
|
||||||
|
|
||||||
|
void SetDocumentTitle(std::string_view title) {
|
||||||
|
// Only meaningful on the client-rendered path. On an SSR'd page the
|
||||||
|
// server already set a correct <title>, and overwriting it with the
|
||||||
|
// same string is pointless work.
|
||||||
|
if (activeWindow && !adoptedSsr) activeWindow->SetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdoptedSsr() { return adoptedSsr; }
|
||||||
|
|
||||||
|
std::string ReadBundleFile(std::string_view name) {
|
||||||
|
// cfg.files flattens to the bundle root (Crafter.Build copies by
|
||||||
|
// filename, not by path), so "content/posts.json" is readable as
|
||||||
|
// "posts.json" here. runtime.js has already fetched every files.json
|
||||||
|
// entry into the VFS before _start, so this cannot block on the
|
||||||
|
// network and cannot fail for a file that is in the manifest.
|
||||||
|
std::ifstream in(std::string(name), std::ios::binary);
|
||||||
|
if (!in) return {};
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
return buf.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Views::SiteContent& SiteData() {
|
||||||
|
if (!contentLoaded) {
|
||||||
|
contentLoaded = true;
|
||||||
|
// Authored content is compiled into the module; only the
|
||||||
|
// pipeline-generated files ride the VFS.
|
||||||
|
content.projects = Content::Projects();
|
||||||
|
content.products = Content::Products();
|
||||||
|
content.legal = Content::LegalPages();
|
||||||
|
content.demos = Content::Demos();
|
||||||
|
content.posts = LoadPosts(ReadBundleFile("posts.json"));
|
||||||
|
content.rates = LoadRates(ReadBundleFile("rates.json"));
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InitializePage() {
|
void InitializePage() {
|
||||||
|
// Two ways a page can arrive, and they need opposite handling.
|
||||||
|
//
|
||||||
|
// 1. Server-rendered (the normal case): #catcrafts-root already exists,
|
||||||
|
// with the chrome and the route's content in it. Building a second
|
||||||
|
// root here would duplicate the header and footer on screen, and
|
||||||
|
// re-rendering <main> would replace correct markup with identical
|
||||||
|
// markup — a wasted round of DOM work and a visible flash for
|
||||||
|
// nothing. So: adopt, and touch nothing.
|
||||||
|
//
|
||||||
|
// 2. The static fallback shell: Caddy serves the wasm bundle's
|
||||||
|
// index.html when the backend is down (see deploy/Caddyfile.example),
|
||||||
|
// and that document has an empty <body>. Here the app IS the whole
|
||||||
|
// site and has to build the chrome and render the route itself.
|
||||||
|
Dom::HtmlElementPtr existing("catcrafts-root");
|
||||||
|
if (existing.ptr != 0) {
|
||||||
|
adoptedSsr = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
root.emplace(Dom::HtmlElement::CreateInBody("div", "catcrafts-root"));
|
root.emplace(Dom::HtmlElement::CreateInBody("div", "catcrafts-root"));
|
||||||
root->SetInnerHTML(R"(
|
root->SetInnerHTML(std::format(
|
||||||
<header>
|
R"(<header id="cc-header">{}</header>)"
|
||||||
<div class="nav-container">
|
R"(<main id="main"></main>)"
|
||||||
<a href="/" class="logo">🐱 Catcrafts</a>
|
R"(<footer id="cc-footer">{}</footer>)",
|
||||||
<nav>
|
Views::RenderNav(RouteKind::Home).Str(),
|
||||||
<ul>
|
Views::RenderFooter().Str()));
|
||||||
<li><a id="blog-nav-button" style="cursor: pointer;" class="active">Blog</a></li>
|
|
||||||
<li><a href="https://forgejo.catcrafts.net/Catcrafts/">Forgejo</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="main"></main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<div class="footer-content">
|
|
||||||
<div class="footer-links">
|
|
||||||
Powered by Crafter.Graphics, Running near native with WASM!
|
|
||||||
<a href="https://forgejo.catcrafts.net/Catcrafts/catcrafts.net">View source</a>
|
|
||||||
</div>
|
|
||||||
<p>© 2026 Catcrafts®. All rights reserved. Crafter® and Catcrafts® are registered trademarks with the EUIPO</p>
|
|
||||||
</div>
|
|
||||||
</footer>)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,27 +22,32 @@ int main() {
|
||||||
// surface to the canvas (full viewport, or the mount element).
|
// surface to the canvas (full viewport, or the mount element).
|
||||||
static Window window(1280, 720, "Catcrafts");
|
static Window window(1280, 720, "Catcrafts");
|
||||||
|
|
||||||
|
// document.title is only reachable through Window::SetTitle, so give the
|
||||||
|
// router a way to set a per-route title without threading a Window
|
||||||
|
// through every render call.
|
||||||
|
SetActiveWindow(&window);
|
||||||
|
|
||||||
// Build the ray-tracing pipeline + scene now (runs StartInit/FinishInit).
|
// Build the ray-tracing pipeline + scene now (runs StartInit/FinishInit).
|
||||||
// The render canvas starts hidden; the demo only traces once a route
|
// The render canvas starts hidden; it only traces once the /demo route
|
||||||
// mounts it into #webgpu-demo (see Catcrafts:Demo).
|
// mounts it into #webgpu-demo (see Catcrafts:Demo).
|
||||||
SetupDemo(window);
|
SetupDemo(window);
|
||||||
|
|
||||||
InitializePage();
|
InitializePage();
|
||||||
|
|
||||||
Router::AddPopStateListener([]{
|
// Back/forward. The Router V1 callback carries no payload, so the route is
|
||||||
RenderRoot(Router::GetPath());
|
// re-read from window.location — which is the right thing regardless,
|
||||||
});
|
// since the location is the single source of truth for what should be on
|
||||||
|
// screen.
|
||||||
|
Router::AddPopStateListener([]{ RenderCurrentRoute(); });
|
||||||
|
|
||||||
static Dom::HtmlElementPtr blogButton("blog-nav-button");
|
RenderCurrentRoute();
|
||||||
blogButton.AddClickListener([](Dom::MouseEvent) {
|
|
||||||
Router::PushState("{}", "", "/blog");
|
|
||||||
RenderRoot("/blog");
|
|
||||||
});
|
|
||||||
|
|
||||||
RenderRoot(Router::GetPath());
|
|
||||||
|
|
||||||
window.Render();
|
window.Render();
|
||||||
window.StartUpdate();
|
window.StartUpdate();
|
||||||
|
// StartSync rather than StayAlive: /demo drives the ray tracer from the
|
||||||
|
// animation-frame loop, so the loop has to exist before that route is
|
||||||
|
// visited. A build of this site without the demo could call StayAlive
|
||||||
|
// instead and skip the permanent rAF tick entirely.
|
||||||
window.StartSync();
|
window.StartSync();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/*
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export module Catcrafts:Blog;
|
|
||||||
import Crafter.Graphics;
|
|
||||||
import std;
|
|
||||||
using namespace Crafter;
|
|
||||||
|
|
||||||
export namespace Catcrafts {
|
|
||||||
struct BlogPost {
|
|
||||||
std::string name;
|
|
||||||
std::string slug;
|
|
||||||
std::string date;
|
|
||||||
std::string content;
|
|
||||||
};
|
|
||||||
std::vector<BlogPost> posts {
|
|
||||||
{
|
|
||||||
"Hello World! 2?",
|
|
||||||
"hello-world-2",
|
|
||||||
"2026-07-18",
|
|
||||||
R"(So this blog has been mega dead but today i bring something exciting that was already released months ago but never deployed xd.
|
|
||||||
|
|
||||||
Crafter.CppDOM is dead, long live Crafter.Graphics!
|
|
||||||
|
|
||||||
This website is now updated to use the new Crafter.Graphics library, which allow for C++ manpiulation of the DOM, and as a new feature WebGPU!
|
|
||||||
|
|
||||||
And what better way to flex WebGPU than ray tracing? Here's a little scene traced live in your browser, shadows and all, straight from the same C++ compiled to WASM that rendered this very post:
|
|
||||||
)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"In WASM, Exit doesn't mean done.",
|
|
||||||
"in-wasm-exit-doesnt-mean-done",
|
|
||||||
"2025-11-14",
|
|
||||||
R"(So if anyone looked at the source code for this website pefore this post you would have seen that everything was allocated with new, for example this very blog was defined as <code>std::vector<BlogPost> posts = new std::vector<BlogPost>{...};</code><br><br>
|
|
||||||
|
|
||||||
Reason for this was that everything became corrupted when callbacking from JS, not knowing sure as to why this was the fastest solution, but after debugging the problem became clear.<br><br>
|
|
||||||
|
|
||||||
When we reach the end of <code>int main()</code> from the eyes of C++ we're finished, deconstruct everything and wrap it up. Unkowing that we just registered a bunch of event handlers with JS.<br><br>
|
|
||||||
|
|
||||||
This caused all the memory corruption errors since everything was destructed.<br><br>
|
|
||||||
|
|
||||||
Luckily this is a very simple fix of adding <code>-fno-c++-static-destructors</code>, and the ease of things like this is also one of the reasons i use clang instead of gcc.<br><br>
|
|
||||||
|
|
||||||
So now all examples and this site have been updated to use normal variables again.<br><br>
|
|
||||||
|
|
||||||
Stick around for the next post for the <strong>CI/CD nightmare</strong> (not for the faint of heart))"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Hello World!",
|
|
||||||
"hello-world",
|
|
||||||
"2025-11-12",
|
|
||||||
R"(Welcome to catcrafts.net!<br><br>
|
|
||||||
Here we believe optimization is everything and C++ is a gift from god.<br>
|
|
||||||
This blog will mostly be dedicated to random tidbits i come across while working on my Crafter series of libraries.<br><br>
|
|
||||||
|
|
||||||
Like this website which is fully written in C++ using the Crafter.Graphics library.<br>
|
|
||||||
And source available too!<br>
|
|
||||||
<a href="https://forgejo.catcrafts.net/Catcrafts/catcrafts.net">https://forgejo.catcrafts.net/Catcrafts/catcrafts.net<a>)"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void RenderBlog();
|
|
||||||
void RenderBlogPost(const std::string_view slug);
|
|
||||||
}
|
|
||||||
|
|
@ -12,5 +12,16 @@ import std;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
export namespace Catcrafts {
|
export namespace Catcrafts {
|
||||||
void RenderRoot(const std::string_view route);
|
// Render the route currently in the address bar and rebind its links.
|
||||||
|
void RenderCurrentRoute();
|
||||||
|
|
||||||
|
// Navigate client-side: push history, then render. Used by the link
|
||||||
|
// interception in BindLinks(); also the entry point for any programmatic
|
||||||
|
// navigation.
|
||||||
|
void NavigateTo(std::string_view path);
|
||||||
|
|
||||||
|
// Link interception is internal to the client-rendered fallback path — a
|
||||||
|
// server-rendered page never intercepts, because normal navigation to
|
||||||
|
// another fully-rendered page is already fast and hijacking it would only
|
||||||
|
// add ways to be wrong.
|
||||||
}
|
}
|
||||||
|
|
@ -6,8 +6,16 @@ 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.
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Browser-side page chrome and content loading.
|
||||||
|
//
|
||||||
|
// This partition is the wasm half of the split: it owns the DOM and the VFS,
|
||||||
|
// and calls into Catcrafts.Shared for every piece of markup. Nothing in here
|
||||||
|
// builds HTML by hand — that all lives in Catcrafts.Shared:Views so the server
|
||||||
|
// renders byte-identical pages.
|
||||||
|
|
||||||
export module Catcrafts:Views;
|
export module Catcrafts:Views;
|
||||||
import Crafter.Graphics;
|
import Crafter.Graphics;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import std;
|
import std;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
|
|
@ -17,6 +25,11 @@ export namespace Catcrafts {
|
||||||
// children don't get yanked out of the DOM. Must be called from main()
|
// children don't get yanked out of the DOM. Must be called from main()
|
||||||
// — the Crafter.Graphics Dom bridge is only safe to use after wasm
|
// — the Crafter.Graphics Dom bridge is only safe to use after wasm
|
||||||
// instantiation hands control to user code.
|
// instantiation hands control to user code.
|
||||||
|
//
|
||||||
|
// When SSR lands this becomes an adopt step rather than a build step: the
|
||||||
|
// chrome will already be in the document and re-creating it would discard
|
||||||
|
// server-rendered markup and flash. Keeping chrome construction separate
|
||||||
|
// from route rendering is what keeps that change local to this function.
|
||||||
void InitializePage();
|
void InitializePage();
|
||||||
|
|
||||||
// Per-render scratch ref to the <main> content container created by
|
// Per-render scratch ref to the <main> content container created by
|
||||||
|
|
@ -24,4 +37,32 @@ export namespace Catcrafts {
|
||||||
// document.getElementById; in return we don't have to thread a long-lived
|
// document.getElementById; in return we don't have to thread a long-lived
|
||||||
// reference across module boundaries.
|
// reference across module boundaries.
|
||||||
inline Dom::HtmlElementPtr MainContent() { return Dom::HtmlElementPtr("main"); }
|
inline Dom::HtmlElementPtr MainContent() { return Dom::HtmlElementPtr("main"); }
|
||||||
|
|
||||||
|
// Site content, parsed once at startup from the bundle. Crafter.Build
|
||||||
|
// copies content/*.json to the bundle root and runtime.js fetches every
|
||||||
|
// files.json entry into memory before _start, so the reads behind this are
|
||||||
|
// plain synchronous ifstreams that never touch the network.
|
||||||
|
const Views::SiteContent& SiteData();
|
||||||
|
|
||||||
|
// Read a file from the wasm VFS by bundle-root name. Returns empty on
|
||||||
|
// failure: a missing content file degrades to an empty section rather than
|
||||||
|
// a broken page.
|
||||||
|
std::string ReadBundleFile(std::string_view name);
|
||||||
|
|
||||||
|
// document.title is reachable only through Window::SetTitle, so the router
|
||||||
|
// needs a Window to set a per-route title. Rather than thread one through
|
||||||
|
// every render call, main() registers the live Window once and the router
|
||||||
|
// calls SetDocumentTitle. A null window makes SetDocumentTitle a no-op, so
|
||||||
|
// ordering mistakes degrade to a stale title instead of a crash.
|
||||||
|
void SetActiveWindow(Window* window);
|
||||||
|
void SetDocumentTitle(std::string_view title);
|
||||||
|
|
||||||
|
// True when the document arrived server-rendered, i.e. InitializePage
|
||||||
|
// adopted existing chrome rather than building it.
|
||||||
|
//
|
||||||
|
// The distinction matters: on an SSR'd page the DOM is already correct for
|
||||||
|
// the URL and must not be re-rendered, while on the static fallback shell
|
||||||
|
// (served when the backend is down) the app has to render everything
|
||||||
|
// itself. Both paths exist, and this is how the router tells them apart.
|
||||||
|
bool AdoptedSsr();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,5 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
||||||
|
|
||||||
export module Catcrafts;
|
export module Catcrafts;
|
||||||
export import :Views;
|
export import :Views;
|
||||||
export import :Blog;
|
|
||||||
export import :Root;
|
export import :Root;
|
||||||
export import :Demo;
|
export import :Demo;
|
||||||
159
project.cpp
159
project.cpp
|
|
@ -11,7 +11,134 @@ import Crafter.Build;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
|
// Two products come out of this repo, selected with `--product=`:
|
||||||
|
//
|
||||||
|
// web (default) — the wasm32-wasip1 browser bundle. Crafter.Graphics for
|
||||||
|
// the DOM, Catcrafts.Shared for the page renderers.
|
||||||
|
// crafter-build
|
||||||
|
//
|
||||||
|
// server — the native HTTP server that will render the same pages
|
||||||
|
// server-side for crawlers and no-JS clients, and later
|
||||||
|
// host the shop API. No Crafter.Graphics.
|
||||||
|
// crafter-build -- --product=server
|
||||||
|
//
|
||||||
|
// One project file rather than three, following the imsd convention: a single
|
||||||
|
// CrafterBuildProject returns a single Configuration, so the products are
|
||||||
|
// branches and the shared library is a `static unique_ptr<Configuration>`
|
||||||
|
// both branches can depend on.
|
||||||
|
|
||||||
|
// Catcrafts.Shared — target-neutral page renderers, built as a static library
|
||||||
|
// for whichever target the selected product is using. `static` because the
|
||||||
|
// Configuration must outlive this function: cfg.dependencies holds a raw
|
||||||
|
// pointer to it.
|
||||||
|
//
|
||||||
|
// `args` MUST carry the consumer's --target=. ApplyStandardArgs is what reads
|
||||||
|
// it, and without it the library silently builds for the host triple while a
|
||||||
|
// wasm consumer links against it — which currently "works" only because there
|
||||||
|
// are no implementation units, so the static archive is empty. The moment a
|
||||||
|
// .cpp lands here that would be a wrong-architecture link.
|
||||||
|
static Configuration* SharedLibrary(std::span<const std::string_view> args) {
|
||||||
|
static auto shared = std::make_unique<Configuration>();
|
||||||
|
shared->path = "./";
|
||||||
|
shared->name = "Catcrafts.Shared";
|
||||||
|
shared->outputName = "Catcrafts.Shared";
|
||||||
|
ApplyStandardArgs(*shared, args); // inherits --target / --debug from the parent
|
||||||
|
shared->type = ConfigurationType::LibraryStatic;
|
||||||
|
|
||||||
|
std::array<fs::path, 9> ifaces = {
|
||||||
|
"shared/interfaces/Catcrafts.Shared",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Html",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Form",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Json",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Model",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Content",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Money",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Route",
|
||||||
|
"shared/interfaces/Catcrafts.Shared-Views",
|
||||||
|
};
|
||||||
|
std::array<fs::path, 0> impls = {};
|
||||||
|
shared->GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return shared.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload trimming, release only.
|
||||||
|
//
|
||||||
|
// The wasi-libc / wasi-libc++ static libs ship with DWARF and wasm-ld keeps
|
||||||
|
// debug sections by default, so a build that never passed -g still ended up
|
||||||
|
// ~84% debug info (3.3 MB of 3.9 MB). Stripping takes the bundle from
|
||||||
|
// 3.9 MB / 1.05 MB gzip to ~700 KB / ~195 KB gzip.
|
||||||
|
static void ApplyReleaseTrimming(Configuration& cfg) {
|
||||||
|
if (cfg.debug) return; // --debug builds want their symbols
|
||||||
|
cfg.compileFlags.push_back("-ffunction-sections");
|
||||||
|
cfg.compileFlags.push_back("-fdata-sections");
|
||||||
|
cfg.linkFlags.push_back("-Wl,--gc-sections");
|
||||||
|
cfg.linkFlags.push_back("-Wl,--strip-debug");
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
bool wantServer = false;
|
||||||
|
for (std::string_view a : args) {
|
||||||
|
if (a == "--product=server") wantServer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── server ────────────────────────────────────────────────────────
|
||||||
|
if (wantServer) {
|
||||||
|
// Crafter.Network supplies the HTTP layer. Notably NOT cpp-httplib or
|
||||||
|
// libcurl: ListenerHTTP1 covers the inbound side (TLS is Caddy's job —
|
||||||
|
// it terminates and reverse-proxies plaintext to localhost, which is
|
||||||
|
// exactly why HTTP/1.1 rather than the HTTP/3 listener, since Caddy
|
||||||
|
// cannot proxy to an h3 upstream), and ClientHTTP1 with
|
||||||
|
// TLSClientCredentials covers outbound HTTPS for the payment and
|
||||||
|
// shipping APIs later — it verifies certificate chain and hostname by
|
||||||
|
// default.
|
||||||
|
std::vector<std::string> netArgs(args.begin(), args.end());
|
||||||
|
bool useLocalNet = false;
|
||||||
|
for (std::string_view a : args) {
|
||||||
|
if (a == "--local") { useLocalNet = true; break; }
|
||||||
|
}
|
||||||
|
Configuration* network = useLocalNet
|
||||||
|
? LocalProject({
|
||||||
|
.projectFile = "../Crafter/Crafter.Network/project.cpp",
|
||||||
|
.args = netArgs,
|
||||||
|
})
|
||||||
|
: GitProject({
|
||||||
|
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git" },
|
||||||
|
.args = netArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "Catcrafts.Server";
|
||||||
|
cfg.outputName = "catcrafts-server";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
ApplyStandardArgs(cfg, args);
|
||||||
|
cfg.dependencies = { SharedLibrary(args), network };
|
||||||
|
|
||||||
|
std::array<fs::path, 1> ifaces = {
|
||||||
|
"server/interfaces/Catcrafts.Server",
|
||||||
|
};
|
||||||
|
std::array<fs::path, 7> impls = {
|
||||||
|
"server/implementations/main",
|
||||||
|
"server/implementations/Catcrafts.Server-Http",
|
||||||
|
"server/implementations/Catcrafts.Server-Orders",
|
||||||
|
"server/implementations/Catcrafts.Server-Mollie",
|
||||||
|
"server/implementations/Catcrafts.Server-Invoice",
|
||||||
|
"server/implementations/Catcrafts.Server-Bunq",
|
||||||
|
"server/implementations/Catcrafts.Server-Shipping",
|
||||||
|
};
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
|
// The bunq client signs requests with an RSA key (OpenSSL EVP). The
|
||||||
|
// TLS transport already links libssl through Crafter.Network; libcrypto
|
||||||
|
// is named explicitly because the signing code calls it directly.
|
||||||
|
cfg.linkFlags.push_back("-lssl");
|
||||||
|
cfg.linkFlags.push_back("-lcrypto");
|
||||||
|
|
||||||
|
ApplyReleaseTrimming(cfg);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── web (default) ─────────────────────────────────────────────────
|
||||||
std::vector<std::string> depArgs(args.begin(), args.end());
|
std::vector<std::string> depArgs(args.begin(), args.end());
|
||||||
depArgs.push_back("--target=wasm32-wasip1");
|
depArgs.push_back("--target=wasm32-wasip1");
|
||||||
|
|
||||||
|
|
@ -44,18 +171,21 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.type = ConfigurationType::Executable;
|
cfg.type = ConfigurationType::Executable;
|
||||||
cfg.target = "wasm32-wasip1";
|
cfg.target = "wasm32-wasip1";
|
||||||
ApplyStandardArgs(cfg, args);
|
ApplyStandardArgs(cfg, args);
|
||||||
cfg.dependencies = { graphics };
|
|
||||||
|
|
||||||
std::array<fs::path, 5> ifaces = {
|
// Catcrafts.Shared has to be built for wasm here, not the host. `args` on
|
||||||
|
// its own does not carry --target= (the caller just runs `crafter-build`),
|
||||||
|
// so hand it the same augmented list the Crafter.Graphics dep gets.
|
||||||
|
std::vector<std::string_view> sharedArgs(depArgs.begin(), depArgs.end());
|
||||||
|
cfg.dependencies = { graphics, SharedLibrary(sharedArgs) };
|
||||||
|
|
||||||
|
std::array<fs::path, 4> ifaces = {
|
||||||
"interfaces/Catcrafts",
|
"interfaces/Catcrafts",
|
||||||
"interfaces/Catcrafts-Views",
|
"interfaces/Catcrafts-Views",
|
||||||
"interfaces/Catcrafts-Blog",
|
|
||||||
"interfaces/Catcrafts-Root",
|
"interfaces/Catcrafts-Root",
|
||||||
"interfaces/Catcrafts-Demo",
|
"interfaces/Catcrafts-Demo",
|
||||||
};
|
};
|
||||||
std::array<fs::path, 5> impls = {
|
std::array<fs::path, 4> impls = {
|
||||||
"implementations/main",
|
"implementations/main",
|
||||||
"implementations/Catcrafts-Blog",
|
|
||||||
"implementations/Catcrafts-Root",
|
"implementations/Catcrafts-Root",
|
||||||
"implementations/Catcrafts-Views",
|
"implementations/Catcrafts-Views",
|
||||||
"implementations/Catcrafts-Demo",
|
"implementations/Catcrafts-Demo",
|
||||||
|
|
@ -64,7 +194,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
|
|
||||||
cfg.files.emplace_back(fs::path("styles/styles.css"));
|
cfg.files.emplace_back(fs::path("styles/styles.css"));
|
||||||
cfg.files.emplace_back(fs::path("robots.txt"));
|
cfg.files.emplace_back(fs::path("robots.txt"));
|
||||||
|
// sitemap.xml and feed.xml are GENERATED by the server product before this
|
||||||
|
// build runs (see .forgejo/workflows/deploy.yaml), from the same route
|
||||||
|
// table and Post model the pages use. Checked-in copies would drift.
|
||||||
cfg.files.emplace_back(fs::path("sitemap.xml"));
|
cfg.files.emplace_back(fs::path("sitemap.xml"));
|
||||||
|
cfg.files.emplace_back(fs::path("feed.xml"));
|
||||||
cfg.files.emplace_back(fs::path("favicon.svg"));
|
cfg.files.emplace_back(fs::path("favicon.svg"));
|
||||||
// WGSL for the ray-traced WebGPU demo embedded in the blog (see
|
// WGSL for the ray-traced WebGPU demo embedded in the blog (see
|
||||||
// interfaces/Catcrafts-Demo.cppm). Fetched at runtime by WebGPUShader.
|
// interfaces/Catcrafts-Demo.cppm). Fetched at runtime by WebGPUShader.
|
||||||
|
|
@ -76,7 +210,22 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
||||||
// Dom partition has no head-element access.
|
// Dom partition has no head-element access.
|
||||||
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
||||||
|
// Product photography. CC BY-SA 4.0, © Fairphone (official render via
|
||||||
|
// Wikimedia Commons) — the attribution lives on /legal/imprint. NOT in
|
||||||
|
// cfg.assets: that pipeline transcodes to .ctex, which <img> cannot decode.
|
||||||
|
cfg.files.emplace_back(fs::path("images/fp6-pmos.jpg"));
|
||||||
|
// ECB rates ride with the content so the wasm-rendered shop (backend-down
|
||||||
|
// fallback) can emit the same indicative prices the server does.
|
||||||
|
cfg.files.emplace_back(fs::path("content/rates.json"));
|
||||||
|
// Site content. Loaded from the VFS at startup rather than compiled in, so
|
||||||
|
// editing a project blurb or refreshing the post list is not a recompile.
|
||||||
|
// NOTE: cfg.files flattens to the bundle root (copied by filename), so
|
||||||
|
// this is read back as "posts.json". Products, projects, legal and demos
|
||||||
|
// are COMPILED IN (Catcrafts.Shared:Content) — only pipeline-generated
|
||||||
|
// data still travels as files.
|
||||||
|
cfg.files.emplace_back(fs::path("content/posts.json"));
|
||||||
|
|
||||||
|
ApplyReleaseTrimming(cfg);
|
||||||
EnableWasiBrowserRuntime(cfg);
|
EnableWasiBrowserRuntime(cfg);
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
# The landing page for a submitted reservation. It also carries
|
||||||
|
# X-Robots-Tag: noindex — this is the belt to that braces, since a crawler that
|
||||||
|
# never fetches the URL cannot index it by accident.
|
||||||
|
Disallow: /reserved
|
||||||
|
|
||||||
Sitemap: https://catcrafts.net/sitemap.xml
|
Sitemap: https://catcrafts.net/sitemap.xml
|
||||||
562
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
562
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The payment rails: bunq (real money) and fake (tests).
|
||||||
|
//
|
||||||
|
// The bunq client speaks the v1 REST API over Crafter.Network's ClientHTTP1
|
||||||
|
// with TLS — no SDK, because the four calls this needs (installation,
|
||||||
|
// device-server, session-server, bunqme-tab) do not justify a dependency, and
|
||||||
|
// every byte in and out goes through the same strict JSON reader as the rest
|
||||||
|
// of the site.
|
||||||
|
//
|
||||||
|
// Context (RSA key, installation token, session token, ids) persists in ONE
|
||||||
|
// JSON file under the service's StateDirectory — never in the repo, never in
|
||||||
|
// the web root. Delete the file and the client re-onboards from the API key.
|
||||||
|
//
|
||||||
|
// A word on trust direction: this code never treats an inbound signal as
|
||||||
|
// authoritative. The ?status= on bunq's redirect back to the order page is
|
||||||
|
// ignored entirely; an order becomes paid ONLY when an authenticated GET to
|
||||||
|
// bunq's API says the tab's payments cover the amount. That is the poll — the
|
||||||
|
// reconciler in Catcrafts.Server-Http.cpp drives it.
|
||||||
|
//
|
||||||
|
// Request signing: bunq stopped REQUIRING body signatures in 2019, but the
|
||||||
|
// keypair exists anyway (installation demands a public key), signing is ~40
|
||||||
|
// lines, and a signed request is valid whether or not the server checks. So
|
||||||
|
// every body is signed — X-Bunq-Client-Signature, RSA-SHA256 over the raw
|
||||||
|
// body, base64.
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <openssl/bio.h>
|
||||||
|
#include <openssl/err.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/rsa.h>
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
import Crafter.Network;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ── small pure helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
std::string Base64(std::span<const unsigned char> in) {
|
||||||
|
static constexpr char tbl[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string out;
|
||||||
|
out.reserve(((in.size() + 2) / 3) * 4);
|
||||||
|
std::size_t i = 0;
|
||||||
|
for (; i + 2 < in.size(); i += 3) {
|
||||||
|
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||||
|
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||||
|
}
|
||||||
|
if (i + 1 == in.size()) {
|
||||||
|
const std::uint32_t n = in[i] << 16;
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||||
|
out += "==";
|
||||||
|
} else if (i + 2 == in.size()) {
|
||||||
|
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||||
|
out += tbl[(n >> 6) & 63]; out += '=';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string JsonEscapeB(std::string_view s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 8);
|
||||||
|
for (const char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RandomHex(std::size_t words) {
|
||||||
|
std::random_device rd;
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first object under any key in bunq's Response array:
|
||||||
|
// {"Response":[{"Id":{...}},{"Token":{...}}]}
|
||||||
|
const Json::Value* FindInResponse(const Json::Value& doc, std::string_view key) {
|
||||||
|
const Json::Value* resp = doc.Find("Response");
|
||||||
|
if (!resp || !resp->IsArray()) return nullptr;
|
||||||
|
for (const Json::Value& item : resp->array) {
|
||||||
|
if (!item.IsObject()) continue;
|
||||||
|
if (const Json::Value* v = item.Find(key)) return v;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||||
|
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||||
|
// spaces, thousands separators — is rejected. Money parsing has no
|
||||||
|
// "probably fine" mode.
|
||||||
|
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||||
|
std::int64_t units = 0;
|
||||||
|
std::size_t i = 0;
|
||||||
|
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||||
|
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||||
|
units = units * 10 + (s[i] - '0');
|
||||||
|
}
|
||||||
|
std::int64_t cents = 0;
|
||||||
|
if (i < s.size()) {
|
||||||
|
if (s[i] != '.') return std::nullopt;
|
||||||
|
++i;
|
||||||
|
const std::size_t fracStart = i;
|
||||||
|
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||||
|
cents = cents * 10 + (s[i] - '0');
|
||||||
|
}
|
||||||
|
const std::size_t digits = i - fracStart;
|
||||||
|
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||||
|
if (digits == 1) cents *= 10;
|
||||||
|
}
|
||||||
|
return units * 100 + cents;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ── the fake rail ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||||
|
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||||
|
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||||
|
// which the test creates when it wants "the customer has paid" to happen.
|
||||||
|
|
||||||
|
class FakeRail final : public PaymentRail {
|
||||||
|
public:
|
||||||
|
explicit FakeRail(std::filesystem::path marker) : marker_(std::move(marker)) {}
|
||||||
|
|
||||||
|
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||||
|
const std::string& redirectUrl) override {
|
||||||
|
static std::atomic<std::int64_t> counter{1};
|
||||||
|
PaymentLink link;
|
||||||
|
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||||
|
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||||
|
// send anyone to, so it points at the order page itself — which keeps
|
||||||
|
// the browser flow usable in dev and the e2e redirect parseable.
|
||||||
|
link.payUrl = redirectUrl;
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||||
|
std::error_code ec;
|
||||||
|
return PaidStatus{
|
||||||
|
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||||
|
"fake" };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Name() const override { return "fake"; }
|
||||||
|
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path marker_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── the bunq rail ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BunqRail final : public PaymentRail {
|
||||||
|
public:
|
||||||
|
explicit BunqRail(RailConfig cfg)
|
||||||
|
: cfg_(std::move(cfg)),
|
||||||
|
host_(cfg_.sandbox ? "public-api.sandbox.bunq.com" : "api.bunq.com") {}
|
||||||
|
|
||||||
|
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||||
|
const std::string& description,
|
||||||
|
const std::string& redirectUrl) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (!EnsureSession()) return std::nullopt;
|
||||||
|
|
||||||
|
const std::string body = std::format(
|
||||||
|
R"({{"bunqme_tab_entry":{{"amount_inquired":{{"value":"{}","currency":"EUR"}},)"
|
||||||
|
R"("description":"{}","redirect_url":"{}"}}}})",
|
||||||
|
Money::FormatMinor(amountMinor), JsonEscapeB(description),
|
||||||
|
JsonEscapeB(redirectUrl));
|
||||||
|
|
||||||
|
auto doc = Call("POST", TabsPath(), body);
|
||||||
|
if (!doc) return std::nullopt;
|
||||||
|
const Json::Value* id = FindInResponse(*doc, "Id");
|
||||||
|
if (!id) return std::nullopt;
|
||||||
|
const std::int64_t tabId = id->Int("id");
|
||||||
|
if (tabId <= 0) return std::nullopt;
|
||||||
|
|
||||||
|
// The POST answers with the id only; the share URL comes from a GET.
|
||||||
|
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||||
|
if (!tab) return std::nullopt;
|
||||||
|
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||||
|
if (!bmt) return std::nullopt;
|
||||||
|
const std::string url(bmt->Str("bunqme_tab_share_url"));
|
||||||
|
if (url.empty()) return std::nullopt;
|
||||||
|
|
||||||
|
PaymentLink link;
|
||||||
|
link.payId = std::to_string(tabId);
|
||||||
|
link.payUrl = url;
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||||
|
std::int64_t expectedMinor) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
// The id is a bunq tab number that travelled through our ledger.
|
||||||
|
std::int64_t tabId = 0;
|
||||||
|
auto [ptr, ec] = std::from_chars(payId.data(), payId.data() + payId.size(), tabId);
|
||||||
|
if (ec != std::errc{} || ptr != payId.data() + payId.size() || tabId <= 0) {
|
||||||
|
return PaidStatus{ PayState::Dead, {} };
|
||||||
|
}
|
||||||
|
if (!EnsureSession()) return std::nullopt;
|
||||||
|
|
||||||
|
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||||
|
if (!tab) return std::nullopt;
|
||||||
|
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||||
|
if (!bmt) return std::nullopt;
|
||||||
|
|
||||||
|
// Sum every settled inquiry on the tab. A tab accepts unlimited
|
||||||
|
// payments until cancelled, so the question is "do the payments cover
|
||||||
|
// the amount", not "is there a payment".
|
||||||
|
std::int64_t paid = 0;
|
||||||
|
if (const Json::Value* inquiries = bmt->Find("result_inquiries");
|
||||||
|
inquiries && inquiries->IsArray()) {
|
||||||
|
for (const Json::Value& entry : inquiries->array) {
|
||||||
|
if (!entry.IsObject()) continue;
|
||||||
|
const Json::Value* payment = entry.Find("payment");
|
||||||
|
if (payment && payment->IsObject()) {
|
||||||
|
if (const Json::Value* inner = payment->Find("Payment");
|
||||||
|
inner && inner->IsObject()) payment = inner;
|
||||||
|
}
|
||||||
|
if (!payment) continue;
|
||||||
|
const Json::Value* amount = payment->Find("amount");
|
||||||
|
if (!amount || !amount->IsObject()) continue;
|
||||||
|
if (amount->Str("currency") != "EUR") continue;
|
||||||
|
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||||
|
paid += *minor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bunq tab never dies on its own — it accepts payments until
|
||||||
|
// cancelled — so the only states here are Paid and Pending. The
|
||||||
|
// method is not identified per payment; "bunq" is honest enough.
|
||||||
|
return PaidStatus{ paid >= expectedMinor ? PayState::Paid : PayState::Pending,
|
||||||
|
"bunq" };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Name() const override { return "bunq"; }
|
||||||
|
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(15); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ── context persistence ───────────────────────────────────────────
|
||||||
|
|
||||||
|
void LoadState() {
|
||||||
|
std::ifstream in(cfg_.statePath, std::ios::binary);
|
||||||
|
if (!in) return;
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
auto doc = Json::Parse(buf.str());
|
||||||
|
if (!doc || !doc->IsObject()) return;
|
||||||
|
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
|
||||||
|
installationToken_ = std::string(doc->Str("installation_token"));
|
||||||
|
deviceRegistered_ = doc->Bool("device_registered");
|
||||||
|
sessionToken_ = std::string(doc->Str("session_token"));
|
||||||
|
userId_ = doc->Int("user_id");
|
||||||
|
accountId_ = doc->Int("account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SaveState() {
|
||||||
|
// 0600 before content: the file holds the private key.
|
||||||
|
std::ofstream out(cfg_.statePath, std::ios::trunc | std::ios::binary);
|
||||||
|
if (!out) return false;
|
||||||
|
out << std::format(
|
||||||
|
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
||||||
|
R"("device_registered":{},"session_token":"{}","user_id":{},"account_id":{}}})",
|
||||||
|
JsonEscapeB(privateKeyPem_), JsonEscapeB(installationToken_),
|
||||||
|
deviceRegistered_, JsonEscapeB(sessionToken_), userId_, accountId_);
|
||||||
|
out.flush();
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::permissions(cfg_.statePath,
|
||||||
|
std::filesystem::perms::owner_read
|
||||||
|
| std::filesystem::perms::owner_write,
|
||||||
|
ec);
|
||||||
|
return static_cast<bool>(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── crypto ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bool EnsureKeypair() {
|
||||||
|
if (!privateKeyPem_.empty()) return LoadKey();
|
||||||
|
EVP_PKEY* raw = EVP_RSA_gen(2048);
|
||||||
|
if (!raw) return false;
|
||||||
|
key_.reset(raw);
|
||||||
|
|
||||||
|
BIO* bio = BIO_new(BIO_s_mem());
|
||||||
|
if (!bio) return false;
|
||||||
|
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) {
|
||||||
|
BIO_free(bio);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char* data = nullptr;
|
||||||
|
const long len = BIO_get_mem_data(bio, &data);
|
||||||
|
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
|
||||||
|
BIO_free(bio);
|
||||||
|
return SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LoadKey() {
|
||||||
|
if (key_) return true;
|
||||||
|
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
|
||||||
|
static_cast<int>(privateKeyPem_.size()));
|
||||||
|
if (!bio) return false;
|
||||||
|
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
|
||||||
|
BIO_free(bio);
|
||||||
|
if (!raw) return false;
|
||||||
|
key_.reset(raw);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PublicKeyPem() {
|
||||||
|
if (!LoadKey()) return {};
|
||||||
|
BIO* bio = BIO_new(BIO_s_mem());
|
||||||
|
if (!bio) return {};
|
||||||
|
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
|
||||||
|
BIO_free(bio);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
char* data = nullptr;
|
||||||
|
const long len = BIO_get_mem_data(bio, &data);
|
||||||
|
std::string pem(data, static_cast<std::size_t>(len));
|
||||||
|
BIO_free(bio);
|
||||||
|
return pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SignBody(std::string_view body) {
|
||||||
|
if (!LoadKey()) return {};
|
||||||
|
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||||
|
if (!ctx) return {};
|
||||||
|
std::string out;
|
||||||
|
do {
|
||||||
|
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, key_.get()) != 1) break;
|
||||||
|
std::size_t len = 0;
|
||||||
|
if (EVP_DigestSign(ctx, nullptr, &len,
|
||||||
|
reinterpret_cast<const unsigned char*>(body.data()),
|
||||||
|
body.size()) != 1) break;
|
||||||
|
std::vector<unsigned char> sig(len);
|
||||||
|
if (EVP_DigestSign(ctx, sig.data(), &len,
|
||||||
|
reinterpret_cast<const unsigned char*>(body.data()),
|
||||||
|
body.size()) != 1) break;
|
||||||
|
sig.resize(len);
|
||||||
|
out = Base64(sig);
|
||||||
|
} while (false);
|
||||||
|
EVP_MD_CTX_free(ctx);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transport ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// One HTTPS call, returning the parsed JSON on 2xx. On 401 with a live
|
||||||
|
// session the caller decides whether to re-session; this layer only
|
||||||
|
// reports. Network and TLS failures land as nullopt — the reconciler
|
||||||
|
// treats that as "unknown, retry later", never as "unpaid".
|
||||||
|
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
|
||||||
|
const std::string& body, const std::string& authToken,
|
||||||
|
std::string* statusOut = nullptr) {
|
||||||
|
try {
|
||||||
|
if (!client_) {
|
||||||
|
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||||
|
host_, static_cast<std::uint16_t>(443),
|
||||||
|
Crafter::TLSClientCredentials{});
|
||||||
|
}
|
||||||
|
Crafter::HTTPRequest req;
|
||||||
|
req.method = std::string(method);
|
||||||
|
req.path = path;
|
||||||
|
req.authority = host_;
|
||||||
|
req.body = body;
|
||||||
|
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||||
|
req.headers["cache-control"] = "no-cache";
|
||||||
|
req.headers["x-bunq-client-request-id"] = RandomHex(4);
|
||||||
|
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
|
||||||
|
req.headers["x-bunq-language"] = "en_US";
|
||||||
|
req.headers["x-bunq-region"] = "nl_NL";
|
||||||
|
if (!body.empty()) {
|
||||||
|
req.headers["content-type"] = "application/json";
|
||||||
|
const std::string sig = SignBody(body);
|
||||||
|
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
|
||||||
|
}
|
||||||
|
if (!authToken.empty()) {
|
||||||
|
req.headers["x-bunq-client-authentication"] = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Crafter::HTTPResponse res = client_->Send(req);
|
||||||
|
if (statusOut) *statusOut = res.status;
|
||||||
|
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||||
|
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path, res.status,
|
||||||
|
res.body.substr(0, 200));
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
auto doc = Json::Parse(res.body);
|
||||||
|
if (!doc) return std::nullopt;
|
||||||
|
return std::move(*doc);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
|
||||||
|
client_.reset(); // dial fresh next time
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session-authenticated call, with one automatic re-session on 401 —
|
||||||
|
// sessions expire server-side and that must not surface as a failure.
|
||||||
|
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
|
||||||
|
const std::string& body) {
|
||||||
|
std::string status;
|
||||||
|
auto doc = DoCall(method, path, body, sessionToken_, &status);
|
||||||
|
if (!doc && status == "401") {
|
||||||
|
sessionToken_.clear();
|
||||||
|
if (!EnsureSession()) return std::nullopt;
|
||||||
|
doc = DoCall(method, path, body, sessionToken_, &status);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── onboarding ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// installation (once, ever) -> installation token
|
||||||
|
// device-server (once, ever) -> binds the API key to this "device"
|
||||||
|
// session-server (per session) -> session token + user id
|
||||||
|
// monetary-account (once) -> account to attach tabs to
|
||||||
|
//
|
||||||
|
// All idempotent to re-run individually; state records how far we got.
|
||||||
|
|
||||||
|
bool EnsureSession() {
|
||||||
|
if (!loaded_) { LoadState(); loaded_ = true; }
|
||||||
|
if (cfg_.apiKey.empty()) {
|
||||||
|
std::println(std::cerr, "bunq: no API key configured");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!EnsureKeypair()) return false;
|
||||||
|
|
||||||
|
if (installationToken_.empty()) {
|
||||||
|
const std::string body =
|
||||||
|
std::format(R"({{"client_public_key":"{}"}})", JsonEscapeB(PublicKeyPem()));
|
||||||
|
auto doc = DoCall("POST", "/v1/installation", body, {});
|
||||||
|
if (!doc) return false;
|
||||||
|
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||||
|
if (!token) return false;
|
||||||
|
installationToken_ = std::string(token->Str("token"));
|
||||||
|
if (installationToken_.empty()) return false;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceRegistered_) {
|
||||||
|
// permitted_ips "*": this box sits on a residential connection
|
||||||
|
// whose address changes; pinning the current IP would brick the
|
||||||
|
// integration on the next DHCP lease. The API key secret still
|
||||||
|
// gates everything.
|
||||||
|
const std::string body = std::format(
|
||||||
|
R"({{"description":"catcrafts.net server","secret":"{}","permitted_ips":["*"]}})",
|
||||||
|
JsonEscapeB(cfg_.apiKey));
|
||||||
|
auto doc = DoCall("POST", "/v1/device-server", body, installationToken_);
|
||||||
|
if (!doc) return false;
|
||||||
|
deviceRegistered_ = true;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionToken_.empty() || userId_ == 0) {
|
||||||
|
const std::string body =
|
||||||
|
std::format(R"({{"secret":"{}"}})", JsonEscapeB(cfg_.apiKey));
|
||||||
|
auto doc = DoCall("POST", "/v1/session-server", body, installationToken_);
|
||||||
|
if (!doc) return false;
|
||||||
|
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||||
|
if (!token) return false;
|
||||||
|
sessionToken_ = std::string(token->Str("token"));
|
||||||
|
// The user object's key varies by account type; take whichever came.
|
||||||
|
for (std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
||||||
|
if (const Json::Value* u = FindInResponse(*doc, k)) {
|
||||||
|
userId_ = u->Int("id");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sessionToken_.empty() || userId_ == 0) return false;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId_ == 0) {
|
||||||
|
auto doc = Call("GET",
|
||||||
|
std::format("/v1/user/{}/monetary-account?count=25", userId_), {});
|
||||||
|
if (!doc) return false;
|
||||||
|
const Json::Value* resp = doc->Find("Response");
|
||||||
|
if (!resp || !resp->IsArray()) return false;
|
||||||
|
for (const Json::Value& item : resp->array) {
|
||||||
|
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
||||||
|
if (acc && acc->Str("status") == "ACTIVE") {
|
||||||
|
accountId_ = acc->Int("id");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (accountId_ == 0) {
|
||||||
|
std::println(std::cerr, "bunq: no active MonetaryAccountBank found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TabsPath() const {
|
||||||
|
return std::format("/v1/user/{}/monetary-account/{}/bunqme-tab", userId_, accountId_);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PkeyDeleter {
|
||||||
|
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||||
|
};
|
||||||
|
|
||||||
|
RailConfig cfg_;
|
||||||
|
std::string host_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||||
|
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
||||||
|
bool loaded_ = false;
|
||||||
|
std::string privateKeyPem_;
|
||||||
|
std::string installationToken_;
|
||||||
|
bool deviceRegistered_ = false;
|
||||||
|
std::string sessionToken_;
|
||||||
|
std::int64_t userId_ = 0;
|
||||||
|
std::int64_t accountId_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// The roster itself (MakeRail) lives in the Mollie unit; these two factories
|
||||||
|
// keep FakeRail/BunqRail construction next to their definitions.
|
||||||
|
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config) {
|
||||||
|
return std::make_unique<FakeRail>(config.statePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config) {
|
||||||
|
return std::make_unique<BunqRail>(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
742
server/implementations/Catcrafts.Server-Http.cpp
Normal file
742
server/implementations/Catcrafts.Server-Http.cpp
Normal file
|
|
@ -0,0 +1,742 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The HTTP layer: server-rendered pages over Crafter.Network's ListenerHTTP1.
|
||||||
|
//
|
||||||
|
// Deployment shape — Caddy terminates TLS and reverse-proxies plaintext to
|
||||||
|
// 127.0.0.1, so this listener speaks HTTP/1.1 without TLS of its own. That is
|
||||||
|
// also why it is ListenerHTTP1 rather than ListenerHTTP: Caddy cannot
|
||||||
|
// reverse_proxy to an HTTP/3 upstream, which ruled out the QUIC listener.
|
||||||
|
//
|
||||||
|
// What this serves and what it does not: pages only. Static assets
|
||||||
|
// (catcrafts.wasm, styles.css, the JS bridges, media) stay with Caddy's
|
||||||
|
// file_server — it does sendfile, precompressed variants and caching far
|
||||||
|
// better than anything worth writing here. Every route below is HTML or XML
|
||||||
|
// generated from Catcrafts.Shared.
|
||||||
|
//
|
||||||
|
// The point of all of it is that a crawler, a reader with JavaScript off, and
|
||||||
|
// the wasm app all get markup from the SAME renderers, so a page cannot mean
|
||||||
|
// one thing to a search engine and another to a visitor.
|
||||||
|
|
||||||
|
module;
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
import Crafter.Network;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Loaded once at startup. The content files are generated at build time (CI
|
||||||
|
// fetches the fediverse posts before the build), so they cannot change under
|
||||||
|
// a running process, and re-reading them per request would be pure waste.
|
||||||
|
Views::SiteContent gContent;
|
||||||
|
std::string gBootScripts;
|
||||||
|
std::string gCssHref = "/styles.css";
|
||||||
|
|
||||||
|
// The payment rail, installed by ConfigurePayments before Serve; a null rail
|
||||||
|
// means checkout answers 503 rather than creating orders nothing can pay.
|
||||||
|
std::unique_ptr<PaymentRail> gRail;
|
||||||
|
std::string gRedirectBase = "https://catcrafts.net";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared ahead: the order page (in RenderPage, below) runs one
|
||||||
|
// reconciliation step on arrival; the definition lives with the checkout
|
||||||
|
// handler further down.
|
||||||
|
struct AdvanceResult {
|
||||||
|
std::string status;
|
||||||
|
std::string paidVia;
|
||||||
|
};
|
||||||
|
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
||||||
|
std::string NowIso8601();
|
||||||
|
|
||||||
|
// Common headers on every HTML response.
|
||||||
|
//
|
||||||
|
// `Cache-Control` is short rather than absent: these pages are cheap to
|
||||||
|
// regenerate, and a minute of shared caching absorbs a burst without making a
|
||||||
|
// content update wait. `X-Content-Type-Options` because a page whose body is
|
||||||
|
// attacker-influenced text should never be sniffed into something executable.
|
||||||
|
void ApplyPageHeaders(HTTPResponse& res, std::string_view contentType,
|
||||||
|
bool cacheable, bool noindex) {
|
||||||
|
res.headers["content-type"] = std::string(contentType);
|
||||||
|
res.headers["x-content-type-options"] = "nosniff";
|
||||||
|
res.headers["referrer-policy"] = "strict-origin-when-cross-origin";
|
||||||
|
res.headers["cache-control"] = cacheable
|
||||||
|
? "public, max-age=60, stale-while-revalidate=600"
|
||||||
|
: "no-store";
|
||||||
|
if (noindex) res.headers["x-robots-tag"] = "noindex, nofollow";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render one route to a full HTTP response.
|
||||||
|
//
|
||||||
|
// The status comes from the renderer, not from this function: RenderRoute
|
||||||
|
// already returns 404 for an unknown path and 301 for a legacy /blog URL. That
|
||||||
|
// is what turns the app's soft-404 into a real one — the wasm app could only
|
||||||
|
// ever render a 404 page under an HTTP 200, which tells a crawler the URL is
|
||||||
|
// valid.
|
||||||
|
HTTPResponse RenderPage(std::string_view target) {
|
||||||
|
const std::string_view path = PathWithoutQueryHTTP(target);
|
||||||
|
// Everything after '?'. ListenerHTTP1 dispatches on the path alone, so the
|
||||||
|
// query has to be recovered from the raw target here.
|
||||||
|
std::string_view query;
|
||||||
|
if (const std::size_t q = target.find('?'); q != std::string_view::npos) {
|
||||||
|
query = target.substr(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Route route = ParseRoute(path, query);
|
||||||
|
|
||||||
|
// The product page embeds the live carrier rate table into its checkout
|
||||||
|
// preview, and that table is runtime state — so, like orders below, it is
|
||||||
|
// rendered here rather than through the shared dispatch (which the wasm
|
||||||
|
// backend-down fallback uses with the zone table only).
|
||||||
|
if (route.kind == RouteKind::Product) {
|
||||||
|
if (const Product* product = gContent.FindProduct(route.slug)) {
|
||||||
|
const ShippingTable ship = CurrentShippingTable();
|
||||||
|
const Views::RenderedPage page =
|
||||||
|
Views::RenderProduct(*product, gContent.rates, ship.perCountry);
|
||||||
|
HTTPResponse res;
|
||||||
|
res.status = std::to_string(page.status);
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||||
|
/*cacheable=*/true, page.meta.noindex);
|
||||||
|
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||||
|
Views::RenderFooter(), {}, gCssHref);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// fall through: unknown slug renders the shared 404 below
|
||||||
|
}
|
||||||
|
|
||||||
|
// The invoice download. Paid orders only; anything else is the same 404
|
||||||
|
// an unknown token gets. The signature requirement is strict: with a key
|
||||||
|
// configured, a signing failure is a 500, never an unsigned invoice.
|
||||||
|
if (route.kind == RouteKind::Invoice) {
|
||||||
|
HTTPResponse res;
|
||||||
|
std::optional<OrderRecord> order = FindOrder(route.slug);
|
||||||
|
if (!order || (order->status != "paid" && order->status != "shipped")) {
|
||||||
|
res.status = "404";
|
||||||
|
ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true);
|
||||||
|
res.body = "Not found\n";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// Orders paid before invoicing existed get their number on first
|
||||||
|
// download — still sequential, just late.
|
||||||
|
if (order->invoiceNumber.empty()) {
|
||||||
|
if (AssignInvoiceNumber(order->token, NowIso8601())) {
|
||||||
|
order = FindOrder(route.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!order || order->invoiceNumber.empty()) {
|
||||||
|
res.status = "500";
|
||||||
|
ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true);
|
||||||
|
res.body = "Could not allocate an invoice number\n";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string productName = order->product;
|
||||||
|
std::string colorLabel = order->color;
|
||||||
|
if (const Product* pr = gContent.FindProduct(order->product)) {
|
||||||
|
productName = pr->name;
|
||||||
|
if (const Variant* v = pr->FindVariant(order->color)) colorLabel = v->label;
|
||||||
|
}
|
||||||
|
std::string body = BuildInvoiceMarkdown(*order, productName, colorLabel);
|
||||||
|
if (InvoiceSigningConfigured()) {
|
||||||
|
const auto signedText = ClearsignInvoice(body);
|
||||||
|
if (!signedText) {
|
||||||
|
res.status = "500";
|
||||||
|
ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true);
|
||||||
|
res.body = "Invoice signing failed; try again shortly\n";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
body = *signedText;
|
||||||
|
} else {
|
||||||
|
body = "UNSIGNED — development copy; production invoices are "
|
||||||
|
"GPG-clearsigned.\n\n" + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status = "200";
|
||||||
|
res.headers["content-type"] = "text/markdown; charset=utf-8";
|
||||||
|
res.headers["content-disposition"] =
|
||||||
|
"attachment; filename=\"catcrafts-invoice-" + order->invoiceNumber + ".md\"";
|
||||||
|
res.headers["cache-control"] = "no-store";
|
||||||
|
res.headers["x-robots-tag"] = "noindex, nofollow";
|
||||||
|
res.headers["x-content-type-options"] = "nosniff";
|
||||||
|
res.body = std::move(body);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orders are the one route whose content lives in server state rather than
|
||||||
|
// the build-time content files, so it is rendered here instead of through
|
||||||
|
// the shared dispatch (whose Order case is the backend-down fallback).
|
||||||
|
if (route.kind == RouteKind::Order) {
|
||||||
|
HTTPResponse res;
|
||||||
|
std::optional<OrderRecord> order = FindOrder(route.slug);
|
||||||
|
if (!order) {
|
||||||
|
// Unknown and malformed tokens are the same 404 — the URL shape
|
||||||
|
// must not reveal whether a token was "close".
|
||||||
|
res.status = "404";
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||||
|
const Views::RenderedPage nf = Views::RenderNotFound(route.path);
|
||||||
|
res.body = Views::RenderDocument(nf, Views::RenderNav(RouteKind::Shop),
|
||||||
|
Views::RenderFooter(), {}, gCssHref);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buyer usually arrives here seconds after paying, redirected by
|
||||||
|
// Mollie — but the reconciler may not have polled yet. Ask the rail
|
||||||
|
// right now so the page they land on already says paid, instead of an
|
||||||
|
// alarming "awaiting payment" that flips ten seconds later. Still the
|
||||||
|
// poll-is-truth rule: this trusts Mollie's authenticated answer, never
|
||||||
|
// the fact of being redirected.
|
||||||
|
if (order->status == "awaiting_payment") {
|
||||||
|
if (const auto advanced = PollAndAdvance(*order)) {
|
||||||
|
order->status = advanced->status;
|
||||||
|
order->paidVia = advanced->paidVia;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderView view;
|
||||||
|
view.token = order->token;
|
||||||
|
view.reference = order->reference;
|
||||||
|
view.status = order->status;
|
||||||
|
view.payUrl = order->payUrl;
|
||||||
|
view.createdAt = order->createdAt;
|
||||||
|
view.country = order->buyer.country;
|
||||||
|
view.goodsMinor = order->goodsMinor;
|
||||||
|
view.shippingMinor = order->shippingMinor;
|
||||||
|
view.totalMinor = order->totalMinor;
|
||||||
|
view.vatIncluded = order->vatIncluded;
|
||||||
|
view.quantity = order->quantity;
|
||||||
|
view.unitMinor = order->unitMinor;
|
||||||
|
if (const Product* p = gContent.FindProduct(order->product)) {
|
||||||
|
view.productName = p->name;
|
||||||
|
if (const Variant* v = p->FindVariant(order->color)) {
|
||||||
|
view.colorLabel = v->label;
|
||||||
|
} else {
|
||||||
|
view.colorLabel = order->color;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view.productName = order->product;
|
||||||
|
view.colorLabel = order->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The indicative national-currency line: ECB reference rates baked in
|
||||||
|
// at build time, converted to whole units, labelled with the rate
|
||||||
|
// date. Purely informative — the euro amount is the charge.
|
||||||
|
std::string indicative;
|
||||||
|
if (auto cur = Money::CurrencyFor(order->buyer.country)) {
|
||||||
|
if (const std::int64_t rate = gContent.rates.Find(cur->code); rate > 0) {
|
||||||
|
indicative = std::format(
|
||||||
|
"{} · ECB reference rate {}",
|
||||||
|
Money::FormatIndicative(*cur,
|
||||||
|
Money::ConvertIndicative(order->totalMinor, rate)),
|
||||||
|
gContent.rates.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Views::RenderedPage page = Views::RenderOrderStatus(view, indicative);
|
||||||
|
res.status = std::to_string(page.status);
|
||||||
|
// Personal content behind a capability URL: never cached anywhere.
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false,
|
||||||
|
/*noindex=*/true);
|
||||||
|
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Shop),
|
||||||
|
Views::RenderFooter(), {}, gCssHref);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Views::RenderedPage page = Views::RenderRoute(route, gContent);
|
||||||
|
|
||||||
|
HTTPResponse res;
|
||||||
|
res.status = std::to_string(page.status);
|
||||||
|
|
||||||
|
// A retired URL is a real redirect, not a rendered page: send 301 with
|
||||||
|
// Location so the crawler updates its index and the visitor's address bar
|
||||||
|
// shows the canonical path. The body is a courtesy for clients that show it.
|
||||||
|
if (!route.canonicalRedirect.empty()) {
|
||||||
|
res.headers["location"] = route.canonicalRedirect;
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||||
|
res.body = "<!doctype html><title>Moved</title><p>Moved to <a href=\""
|
||||||
|
+ route.canonicalRedirect + "\">" + route.canonicalRedirect + "</a>.";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||||
|
/*cacheable=*/page.status == 200, page.meta.noindex);
|
||||||
|
|
||||||
|
// Boot scripts only where the module is actually needed, and that is a
|
||||||
|
// property of the demo rather than of the route: a demo entry declares
|
||||||
|
// needsWasm, so adding one that does not need the renderer costs no change
|
||||||
|
// here. Every other page is complete without it, and shipping ~239 KB of
|
||||||
|
// module to them would buy nothing.
|
||||||
|
bool wantsWasm = false;
|
||||||
|
if (route.kind == RouteKind::Demo) {
|
||||||
|
if (const Demo* d = gContent.FindDemo(route.slug)) wantsWasm = d->needsWasm;
|
||||||
|
}
|
||||||
|
res.body = Views::RenderDocument(page,
|
||||||
|
Views::RenderNav(route.kind == RouteKind::LegacyBlog
|
||||||
|
? RouteKind::Posts : route.kind),
|
||||||
|
Views::RenderFooter(),
|
||||||
|
wantsWasm ? gBootScripts : std::string_view{},
|
||||||
|
gCssHref);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPResponse ServeSitemap() {
|
||||||
|
HTTPResponse res;
|
||||||
|
std::string out = "<?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()) {
|
||||||
|
out += " <url><loc>https://catcrafts.net";
|
||||||
|
out += Html::Escape(p).Str();
|
||||||
|
out += "</loc></url>\n";
|
||||||
|
}
|
||||||
|
// From the catalogue, not a second hardcoded list.
|
||||||
|
for (const Product& pr : gContent.products) {
|
||||||
|
out += " <url><loc>https://catcrafts.net/shop/";
|
||||||
|
out += Html::Escape(pr.slug).Str();
|
||||||
|
out += "</loc></url>\n";
|
||||||
|
}
|
||||||
|
out += "</urlset>\n";
|
||||||
|
ApplyPageHeaders(res, "application/xml; charset=utf-8", true, false);
|
||||||
|
res.body = std::move(out);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPResponse ServeFeed() {
|
||||||
|
HTTPResponse res;
|
||||||
|
ApplyPageHeaders(res, "application/atom+xml; charset=utf-8", true, false);
|
||||||
|
res.body = Views::RenderAtomFeed(gContent.posts);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A very coarse rate limit on checkout submissions.
|
||||||
|
//
|
||||||
|
// Not a general-purpose limiter, and deliberately not per-IP: the server sits
|
||||||
|
// behind Caddy, so every request arrives from 127.0.0.1 unless forwarding
|
||||||
|
// headers are trusted — and trusting a client-settable header for rate limiting
|
||||||
|
// is worse than not limiting at all. So this is a global cap, which is the
|
||||||
|
// honest thing a reverse-proxied process can enforce by itself. Per-IP limiting
|
||||||
|
// belongs in Caddy, where the real peer address lives.
|
||||||
|
//
|
||||||
|
// The intent is only to stop a script filling the file overnight; the honeypot
|
||||||
|
// handles ordinary bots and Caddy handles volume.
|
||||||
|
std::mutex gRateMutex;
|
||||||
|
std::deque<std::chrono::steady_clock::time_point> gRecentSubmissions;
|
||||||
|
constexpr std::size_t kMaxSubmissionsPerWindow = 30;
|
||||||
|
constexpr auto kRateWindow = std::chrono::minutes(10);
|
||||||
|
|
||||||
|
bool RateLimitAllows() {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
std::lock_guard lock(gRateMutex);
|
||||||
|
while (!gRecentSubmissions.empty() && now - gRecentSubmissions.front() > kRateWindow) {
|
||||||
|
gRecentSubmissions.pop_front();
|
||||||
|
}
|
||||||
|
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
|
||||||
|
gRecentSubmissions.push_back(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 3339 UTC. Recorded so the order log can be read chronologically
|
||||||
|
// without depending on file order.
|
||||||
|
std::string NowIso8601() {
|
||||||
|
return std::format("{:%FT%TZ}",
|
||||||
|
std::chrono::floor<std::chrono::seconds>(
|
||||||
|
std::chrono::system_clock::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /shop/<slug> — create an order.
|
||||||
|
//
|
||||||
|
// The sequence is: validate -> compute the amount SERVER-SIDE -> get a payment
|
||||||
|
// link from the rail -> persist the order -> 303 to /order/<token>. The
|
||||||
|
// payment link is fetched before the order is written so a rail failure never
|
||||||
|
// strands an unpayable order; the buyer just gets an honest error and their
|
||||||
|
// form back.
|
||||||
|
//
|
||||||
|
// Answers 303 on success rather than rendering the order page inline. That is
|
||||||
|
// the POST/redirect/GET pattern, and it matters for a real form: a rendered
|
||||||
|
// POST response means reloading re-submits, and the back button re-posts. The
|
||||||
|
// redirect leaves the browser on a GET it can safely repeat.
|
||||||
|
HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
||||||
|
HTTPResponse res;
|
||||||
|
|
||||||
|
const Product* product = gContent.FindProduct(route.slug);
|
||||||
|
if (!product) {
|
||||||
|
res.status = "404";
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||||
|
const Views::RenderedPage page = Views::RenderNotFound(route.path);
|
||||||
|
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Shop),
|
||||||
|
Views::RenderFooter(), {}, gCssHref);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the product page with errors and the submitted values kept, so a
|
||||||
|
// validation failure never costs the visitor what they typed.
|
||||||
|
const ShippingTable shipTable = CurrentShippingTable();
|
||||||
|
auto reject = [&](std::vector<Form::FieldError> errors,
|
||||||
|
const Form::Checkout& prev,
|
||||||
|
std::string_view status) {
|
||||||
|
res.status = std::string(status);
|
||||||
|
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||||
|
const Views::RenderedPage page = Views::RenderProduct(
|
||||||
|
*product, gContent.rates, shipTable.perCountry, errors, prev);
|
||||||
|
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||||
|
Views::RenderFooter(), {}, gCssHref);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only urlencoded — the form sends nothing else, and accepting more content
|
||||||
|
// types means parsing more attacker-chosen formats for no benefit.
|
||||||
|
const auto ct = req.headers.find("content-type");
|
||||||
|
if (ct != req.headers.end() && ct->second.find("application/x-www-form-urlencoded")
|
||||||
|
== std::string::npos) {
|
||||||
|
return reject({{ "", "Unsupported form encoding." }}, {}, "415");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto fields = Form::ParseUrlEncoded(req.body);
|
||||||
|
if (!fields) {
|
||||||
|
// Oversized or malformed body. 413 rather than 400 when it is a size
|
||||||
|
// problem, since that is actionable.
|
||||||
|
return reject({{ "", "That submission was too large or malformed." }}, {},
|
||||||
|
req.body.size() > Form::kMaxBodyBytes ? "413" : "400");
|
||||||
|
}
|
||||||
|
|
||||||
|
Form::CheckoutResult parsed = Form::ValidateCheckout(*fields);
|
||||||
|
if (!parsed.Ok()) {
|
||||||
|
return reject(parsed.errors, parsed.value, "422");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product->Buyable()) {
|
||||||
|
return reject({{ "", product->ComingSoon()
|
||||||
|
? "The shop has not opened yet. Nothing was charged."
|
||||||
|
: "This product is temporarily unavailable." }},
|
||||||
|
parsed.value, "409");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gRail) {
|
||||||
|
return reject({{ "", "Checkout is offline right now — nothing was charged. "
|
||||||
|
"Please try again later." }}, parsed.value, "503");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RateLimitAllows()) {
|
||||||
|
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
||||||
|
parsed.value, "429");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The variant: submitted slug against the catalogue, defaulting to the
|
||||||
|
// cheapest (which is what the page advertises). A slug we never listed is
|
||||||
|
// a 422, not a guess — a tampered value must not buy an unpriced colour.
|
||||||
|
const Variant* variant = nullptr;
|
||||||
|
if (!product->variants.empty()) {
|
||||||
|
variant = parsed.value.color.empty()
|
||||||
|
? product->CheapestVariant()
|
||||||
|
: product->FindVariant(parsed.value.color);
|
||||||
|
if (!variant) {
|
||||||
|
return reject({{ "color", "That is not one of the colours." }},
|
||||||
|
parsed.value, "422");
|
||||||
|
}
|
||||||
|
parsed.value.color = variant->slug;
|
||||||
|
}
|
||||||
|
const std::int64_t unitMinor =
|
||||||
|
variant ? variant->priceInclMinor : product->priceInclMinor;
|
||||||
|
|
||||||
|
// THE amount. Computed here from the catalogue, the validated country and
|
||||||
|
// the live shipping table; nothing about money ever arrives from the
|
||||||
|
// client. Shipping is per order, not per unit — one parcel.
|
||||||
|
const std::int64_t shippingMinor = ShipCostFor(
|
||||||
|
parsed.value.country, product->shipNlMinor, product->shipEuMinor,
|
||||||
|
product->shipWorldMinor);
|
||||||
|
const Money::Totals totals = Money::ComputeTotals(
|
||||||
|
unitMinor, parsed.value.quantity, shippingMinor, parsed.value.country);
|
||||||
|
|
||||||
|
OrderRecord order;
|
||||||
|
order.token = NewOrderToken();
|
||||||
|
order.reference = ReferenceFromToken(order.token);
|
||||||
|
order.product = product->slug;
|
||||||
|
order.color = parsed.value.color;
|
||||||
|
order.quantity = parsed.value.quantity;
|
||||||
|
order.unitMinor = unitMinor;
|
||||||
|
order.createdAt = NowIso8601();
|
||||||
|
order.buyer = parsed.value;
|
||||||
|
order.goodsMinor = totals.goods;
|
||||||
|
order.shippingMinor = totals.shipping;
|
||||||
|
order.totalMinor = totals.total;
|
||||||
|
order.vatIncluded = totals.vatIncluded;
|
||||||
|
|
||||||
|
auto link = gRail->CreateLink(
|
||||||
|
order.totalMinor,
|
||||||
|
std::format("{} catcrafts.net", order.reference),
|
||||||
|
std::format("{}/order/{}", gRedirectBase, order.token));
|
||||||
|
if (!link) {
|
||||||
|
return reject({{ "", "The payment provider can't be reached right now — "
|
||||||
|
"nothing was charged and no order was created. "
|
||||||
|
"Please try again in a few minutes." }},
|
||||||
|
parsed.value, "502");
|
||||||
|
}
|
||||||
|
order.payUrl = link->payUrl;
|
||||||
|
order.payId = link->payId;
|
||||||
|
|
||||||
|
if (!CreateOrder(order)) {
|
||||||
|
// Storage failed (no path configured, disk full, permissions). Tell the
|
||||||
|
// truth: a payment link over an order that was never written is the
|
||||||
|
// worst possible outcome here.
|
||||||
|
return reject({{ "", "Couldn't record the order — something is wrong on this "
|
||||||
|
"end. Nothing was charged. Please try again later." }},
|
||||||
|
parsed.value, "500");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println(std::cerr, "order {} created: {} {} -> {}", order.reference,
|
||||||
|
Money::FormatMinor(order.totalMinor), order.buyer.country,
|
||||||
|
gRail->Name());
|
||||||
|
|
||||||
|
// Straight to the payment page — the buyer clicked "buy", not "read an
|
||||||
|
// interim status page". The order page stays the receipt/status URL that
|
||||||
|
// Mollie redirects back to afterwards.
|
||||||
|
res.status = "303";
|
||||||
|
res.headers["location"] = order.payUrl;
|
||||||
|
res.headers["cache-control"] = "no-store";
|
||||||
|
res.headers["content-type"] = "text/html; charset=utf-8";
|
||||||
|
res.body = "<!doctype html><title>Order created</title><p>Order created. "
|
||||||
|
"<a href=\"" + order.payUrl + "\">Continue to payment</a>.";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One reconciliation step for one order: ask the rail, append the transition
|
||||||
|
// if there is one, and report the order's (possibly new) status fields.
|
||||||
|
// Shared by the reconciler thread and the order page's on-arrival check.
|
||||||
|
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
|
||||||
|
if (!gRail || order.status != "awaiting_payment") return std::nullopt;
|
||||||
|
const std::optional<PaidStatus> paid = gRail->CheckPaid(order.payId, order.totalMinor);
|
||||||
|
if (!paid.has_value()) return std::nullopt;
|
||||||
|
if (paid->state == PayState::Paid) {
|
||||||
|
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
||||||
|
// The invoice number exists from the moment the money does —
|
||||||
|
// sequential by payment order, which is what the bookkeeping wants.
|
||||||
|
AssignInvoiceNumber(order.token, NowIso8601());
|
||||||
|
std::println(std::cerr, "order {} paid ({}, via {})", order.reference,
|
||||||
|
Money::FormatMinor(order.totalMinor),
|
||||||
|
paid->method.empty() ? "?" : paid->method);
|
||||||
|
return AdvanceResult{ "paid", paid->method };
|
||||||
|
}
|
||||||
|
} else if (paid->state == PayState::Dead) {
|
||||||
|
if (AppendOrderStatus(order.token, "cancelled", NowIso8601())) {
|
||||||
|
std::println(std::cerr, "order {} lapsed (payment {})",
|
||||||
|
order.reference, order.payId);
|
||||||
|
return AdvanceResult{ "cancelled", {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liveness for Caddy's health_uri and for the deploy script. Deliberately does
|
||||||
|
// not touch the content or render anything, so it stays true even if a content
|
||||||
|
// file is malformed.
|
||||||
|
HTTPResponse ServeHealth() {
|
||||||
|
HTTPResponse res;
|
||||||
|
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||||
|
res.headers["cache-control"] = "no-store";
|
||||||
|
res.body = std::format("ok\nprojects={}\nposts={}\n",
|
||||||
|
gContent.projects.size(), gContent.posts.size());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crafter.Build emits the boot scripts with RELATIVE srcs — src="runtime.js?v=…"
|
||||||
|
// — which the browser resolves against the current directory. That is correct at
|
||||||
|
// "/" and wrong at every deeper path: on /demos/raytracer it asks for
|
||||||
|
// /demos/runtime.js, which does not exist, so Caddy's try_files hands back
|
||||||
|
// index.html and the browser blocks the module for having a text/html MIME type.
|
||||||
|
// The symptom is four NS_ERROR_CORRUPTED_CONTENT failures and a dead page.
|
||||||
|
//
|
||||||
|
// Rooting the src makes one tag correct at any depth, which matters because
|
||||||
|
// every product and legal page is two segments deep.
|
||||||
|
std::string RootRelativeSrc(std::string tag) {
|
||||||
|
const std::size_t at = tag.find("src=\"");
|
||||||
|
if (at == std::string::npos) return tag;
|
||||||
|
const std::size_t v = at + 5;
|
||||||
|
if (v >= tag.size()) return tag;
|
||||||
|
const std::string_view rest = std::string_view(tag).substr(v);
|
||||||
|
// A leading '/' covers both "/runtime.js" and protocol-relative "//host/x";
|
||||||
|
// both are already absolute and must be left alone.
|
||||||
|
if (rest.starts_with("/") || rest.starts_with("http://") || rest.starts_with("https://"))
|
||||||
|
return tag;
|
||||||
|
tag.insert(v, "/");
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void LoadContent(const std::filesystem::path& contentDir,
|
||||||
|
const std::filesystem::path& bundleIndexHtml) {
|
||||||
|
// Authored content is compiled in; only pipeline-generated data (posts,
|
||||||
|
// rates) is read from disk.
|
||||||
|
gContent.projects = Content::Projects();
|
||||||
|
gContent.products = Content::Products();
|
||||||
|
gContent.legal = Content::LegalPages();
|
||||||
|
gContent.demos = Content::Demos();
|
||||||
|
gContent.posts = LoadPosts(ReadFile(contentDir / "posts.json"));
|
||||||
|
gContent.rates = LoadRates(ReadFile(contentDir / "rates.json"));
|
||||||
|
|
||||||
|
// The <script> tags Crafter.Build generated into the wasm bundle's
|
||||||
|
// index.html, lifted verbatim. They carry a ?v=<buildId> cache buster that
|
||||||
|
// changes every build, so hardcoding them here would go stale silently and
|
||||||
|
// serve a mismatched module. Extracting them keeps one source of truth.
|
||||||
|
if (!bundleIndexHtml.empty()) {
|
||||||
|
const std::string index = ReadFile(bundleIndexHtml);
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = index.find("<script", pos)) != std::string::npos) {
|
||||||
|
const std::size_t end = index.find("</script>", pos);
|
||||||
|
if (end == std::string::npos) break;
|
||||||
|
gBootScripts += RootRelativeSrc(index.substr(pos, end + 9 - pos));
|
||||||
|
gBootScripts += '\n';
|
||||||
|
pos = end + 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t ContentPostCount() { return gContent.posts.size(); }
|
||||||
|
std::size_t ContentProjectCount() { return gContent.projects.size(); }
|
||||||
|
std::size_t ContentProductCount() { return gContent.products.size(); }
|
||||||
|
|
||||||
|
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase) {
|
||||||
|
gRail = std::move(rail);
|
||||||
|
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The reconciler: the ONLY thing that moves an order to paid.
|
||||||
|
//
|
||||||
|
// The design rule from the plan holds even without webhooks: payment state
|
||||||
|
// comes from an authenticated poll against the provider, never from anything
|
||||||
|
// the client (or a redirect parameter) says. This thread sweeps awaiting
|
||||||
|
// orders and asks the rail; a positive answer appends a status event.
|
||||||
|
//
|
||||||
|
// Poll pacing backs off with order age — a buyer mid-flow gets answers in
|
||||||
|
// seconds, a day-old order gets checked hourly, and after seven days the
|
||||||
|
// order stops being polled (a very late payment is then found by the manual
|
||||||
|
// CLI path, which exists for exactly that).
|
||||||
|
void ReconcilerLoop(const std::stop_token& stop) {
|
||||||
|
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastPoll;
|
||||||
|
|
||||||
|
while (!stop.stop_requested()) {
|
||||||
|
std::this_thread::sleep_for(gRail->PollInterval());
|
||||||
|
if (stop.stop_requested()) break;
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
for (const OrderRecord& order : ListOrders()) {
|
||||||
|
if (order.status != "awaiting_payment") {
|
||||||
|
lastPoll.erase(order.token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Age from the record's own timestamp is string math we don't
|
||||||
|
// need: steady-clock first-seen is good enough for backoff.
|
||||||
|
auto [it, inserted] = lastPoll.try_emplace(order.token, now);
|
||||||
|
if (!inserted) {
|
||||||
|
const auto sinceFirst = now - it->second;
|
||||||
|
// it->second tracks FIRST time seen; store poll pacing in a
|
||||||
|
// parallel structure? One map is enough: after the first
|
||||||
|
// pass, re-poll every interval for 2 h, then only every
|
||||||
|
// 10 min, dropping to nothing after 7 days.
|
||||||
|
using namespace std::chrono;
|
||||||
|
if (sinceFirst > hours(24 * 7)) continue;
|
||||||
|
if (sinceFirst > hours(2)) {
|
||||||
|
// Coarse modulo pacing: only act on passes that land in
|
||||||
|
// the first interval of every 10-minute window.
|
||||||
|
const auto inWindow = duration_cast<seconds>(sinceFirst) % minutes(10);
|
||||||
|
if (inWindow > gRail->PollInterval() * 2) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paid, lapsed (the provider says the payment can never arrive),
|
||||||
|
// or nothing to report — the shared step handles the transition.
|
||||||
|
if (PollAndAdvance(order)) lastPoll.erase(order.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int Serve(std::uint16_t port) {
|
||||||
|
// Exact-match routes for the fixed set, and a fallback for everything else.
|
||||||
|
//
|
||||||
|
// The fallback is what makes this work at all: the app's own ParseRoute is
|
||||||
|
// the single route table shared with the wasm frontend, so rather than
|
||||||
|
// enumerate paths here (and risk the two disagreeing), unmatched requests
|
||||||
|
// are handed straight to it. It also means /shop/<slug> and /order/<token>
|
||||||
|
// need no listener change when they arrive — they are just more paths
|
||||||
|
// ParseRoute already knows about.
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes{
|
||||||
|
{ "/sitemap.xml", [](const HTTPRequest&) { return ServeSitemap(); } },
|
||||||
|
{ "/feed.xml", [](const HTTPRequest&) { return ServeFeed(); } },
|
||||||
|
{ "/api/healthz", [](const HTTPRequest&) { return ServeHealth(); } },
|
||||||
|
};
|
||||||
|
|
||||||
|
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
|
||||||
|
// A POST to a product page is a checkout submission.
|
||||||
|
if (req.method == "POST") {
|
||||||
|
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
||||||
|
if (route.kind == RouteKind::Product) return HandleCheckout(req, route);
|
||||||
|
HTTPResponse res;
|
||||||
|
res.status = "405";
|
||||||
|
res.headers["allow"] = "GET, HEAD";
|
||||||
|
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||||
|
res.body = "Method not allowed\n";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// Only GET and HEAD reach a page. Anything else against a page URL is a
|
||||||
|
// client error, and answering 405 with Allow is more useful than
|
||||||
|
// rendering a page for a request that will be silently ignored.
|
||||||
|
if (req.method != "GET" && req.method != "HEAD") {
|
||||||
|
HTTPResponse res;
|
||||||
|
res.status = "405";
|
||||||
|
res.headers["allow"] = "GET, HEAD, POST";
|
||||||
|
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||||
|
res.body = "Method not allowed\n";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return RenderPage(req.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reconciler only exists when there is a rail to ask. jthread: the
|
||||||
|
// stop token fires on destruction, so shutdown does not hang on a sleep.
|
||||||
|
std::optional<std::jthread> reconciler;
|
||||||
|
if (gRail) {
|
||||||
|
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shipping rates: one fetch at startup, then daily. RefreshShippingTable
|
||||||
|
// is a no-op without Sendcloud credentials, and every failure mode leaves
|
||||||
|
// the previous table (cached or zone fallback) in charge.
|
||||||
|
std::jthread shippingRefresher([](std::stop_token st) {
|
||||||
|
RefreshShippingTable();
|
||||||
|
while (!st.stop_requested()) {
|
||||||
|
for (int i = 0; i < 24 * 60 && !st.stop_requested(); ++i) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::minutes(1));
|
||||||
|
}
|
||||||
|
if (!st.stop_requested()) RefreshShippingTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
|
||||||
|
std::println("catcrafts-server: listening on 127.0.0.1:{} "
|
||||||
|
"({} projects, {} posts, payments: {})",
|
||||||
|
port, gContent.projects.size(), gContent.posts.size(),
|
||||||
|
gRail ? gRail->Name() : "off");
|
||||||
|
listener.Listen();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
186
server/implementations/Catcrafts.Server-Invoice.cpp
Normal file
186
server/implementations/Catcrafts.Server-Invoice.cpp
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Invoices: markdown, clearsigned with GPG.
|
||||||
|
//
|
||||||
|
// Markdown because an invoice's job is to be READ — by the buyer, by an
|
||||||
|
// accountant, by a tax office, in thirty years, with any text editor. A
|
||||||
|
// clearsigned document keeps the text human-readable with the signature
|
||||||
|
// inline (gpg --verify checks it), so authenticity does not depend on this
|
||||||
|
// server still existing — which is the point: the buyer downloads the file
|
||||||
|
// once and the shop makes no promise to host receipt pages forever.
|
||||||
|
//
|
||||||
|
// Signing shells out to the gpg binary rather than linking a PGP library:
|
||||||
|
// the key management story (GNUPGHOME, agent, key generation) is exactly the
|
||||||
|
// part a library reimplements badly, and the server signs a handful of
|
||||||
|
// documents per week. The subprocess writes to files under a private
|
||||||
|
// directory, never a shell-interpolated user string — the only variable in
|
||||||
|
// the command line is the key id, validated to a safe alphabet.
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string gGpgKeyId;
|
||||||
|
|
||||||
|
// The registered business identity. On every invoice — these are the fields
|
||||||
|
// a Dutch invoice must carry along with the sequential number and amounts.
|
||||||
|
constexpr std::string_view kSellerName = "Catcrafts";
|
||||||
|
constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
|
||||||
|
constexpr std::string_view kSellerCity = "3315NN Dordrecht";
|
||||||
|
constexpr std::string_view kSellerKvk = "78437059";
|
||||||
|
constexpr std::string_view kSellerVat = "NL003329281B38";
|
||||||
|
constexpr std::string_view kSellerSite = "catcrafts.net";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string BuildInvoiceMarkdown(const OrderRecord& o,
|
||||||
|
std::string_view productName,
|
||||||
|
std::string_view colorLabel) {
|
||||||
|
std::string md;
|
||||||
|
md.reserve(2048);
|
||||||
|
|
||||||
|
const std::string item = colorLabel.empty()
|
||||||
|
? std::string(productName)
|
||||||
|
: std::format("{} — {}", productName, colorLabel);
|
||||||
|
|
||||||
|
// The number scheme continues the pre-shop administration: the customer
|
||||||
|
// number is a UUID series, the invoice number counts within it.
|
||||||
|
const std::size_t dash = o.invoiceNumber.size() > 37 ? 36 : std::string::npos;
|
||||||
|
const std::string customer = dash != std::string::npos
|
||||||
|
? o.invoiceNumber.substr(0, 36) : o.invoiceNumber;
|
||||||
|
const std::string seq = dash != std::string::npos
|
||||||
|
? o.invoiceNumber.substr(37) : o.invoiceNumber;
|
||||||
|
|
||||||
|
md += std::format("# Invoice {}\n\n", o.invoiceNumber);
|
||||||
|
md += std::format("**{}** \n{} \n{} \nKVK {} · VAT {} · {}\n\n",
|
||||||
|
kSellerName, kSellerStreet, kSellerCity,
|
||||||
|
kSellerKvk, kSellerVat, kSellerSite);
|
||||||
|
// "*" bullets, never "-": clearsigning dash-escapes lines that start
|
||||||
|
// with a dash ("- - Invoice date"), and the raw file is meant to be read.
|
||||||
|
md += std::format("* Customer number: {}\n", customer);
|
||||||
|
md += std::format("* Invoice number: {}\n", seq);
|
||||||
|
md += std::format("* Invoice date: {}\n", o.invoicedAt);
|
||||||
|
md += std::format("* Order reference: {}\n", o.reference);
|
||||||
|
md += std::format("* Order placed: {}\n", o.createdAt);
|
||||||
|
if (!o.paidVia.empty()) {
|
||||||
|
md += std::format("* Paid via: {}\n", o.paidVia);
|
||||||
|
}
|
||||||
|
md += "\n## Billed and shipped to\n\n";
|
||||||
|
md += std::format("{} \n{} \n{} {} \n{}\n\n",
|
||||||
|
o.buyer.name, o.buyer.street, o.buyer.postal,
|
||||||
|
o.buyer.city, o.buyer.country);
|
||||||
|
|
||||||
|
md += "## Amounts\n\n";
|
||||||
|
md += "| Description | Qty | Amount |\n|---|---|---|\n";
|
||||||
|
if (o.vatIncluded) {
|
||||||
|
// EU supply: net amounts per line, VAT once over the taxable total —
|
||||||
|
// the same line-total rounding the checkout charged with.
|
||||||
|
const std::int64_t net = Money::NetFromGross(o.totalMinor);
|
||||||
|
const std::int64_t vat = o.totalMinor - net;
|
||||||
|
md += std::format("| {} | {} | {} |\n", item, o.quantity,
|
||||||
|
Money::FormatEuro(Money::NetFromGross(o.goodsMinor)));
|
||||||
|
md += std::format("| Shipping | 1 | {} |\n",
|
||||||
|
Money::FormatEuro(Money::NetFromGross(o.shippingMinor)));
|
||||||
|
md += std::format("| Subtotal (ex VAT) | | {} |\n", Money::FormatEuro(net));
|
||||||
|
md += std::format("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat));
|
||||||
|
md += std::format("| **Total (incl. VAT)** | | **{}** |\n",
|
||||||
|
Money::FormatEuro(o.totalMinor));
|
||||||
|
} else {
|
||||||
|
md += std::format("| {} | {} | {} |\n", item, o.quantity,
|
||||||
|
Money::FormatEuro(o.goodsMinor));
|
||||||
|
md += std::format("| Shipping | 1 | {} |\n",
|
||||||
|
Money::FormatEuro(o.shippingMinor));
|
||||||
|
md += std::format("| **Total** | | **{}** |\n",
|
||||||
|
Money::FormatEuro(o.totalMinor));
|
||||||
|
md += "\nVAT 0%: zero-rated export outside the EU "
|
||||||
|
"(art. 146 EU VAT Directive). Import duties and taxes are levied "
|
||||||
|
"by the destination country and are not part of this invoice.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
md += "\nThis invoice was generated by catcrafts.net and signed with the "
|
||||||
|
"shop's GPG key. Verify with: gpg --verify <this file>\n";
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigureInvoicing(std::string gpgKeyId) {
|
||||||
|
// The key id ends up on a command line — constrain it to the alphabet a
|
||||||
|
// fingerprint or uid email actually needs, and refuse anything else.
|
||||||
|
for (const char c : gpgKeyId) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
|| (c >= '0' && c <= '9') || c == '@' || c == '.'
|
||||||
|
|| c == '_' || c == '-' || c == '+';
|
||||||
|
if (!ok) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"invoice: refusing GPG key id with unexpected characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gGpgKeyId = std::move(gpgKeyId);
|
||||||
|
if (!gGpgKeyId.empty()) {
|
||||||
|
std::println(std::cerr, "invoice: signing with GPG key '{}'", gGpgKeyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InvoiceSigningConfigured() { return !gGpgKeyId.empty(); }
|
||||||
|
|
||||||
|
std::optional<std::string> ClearsignInvoice(const std::string& markdown) {
|
||||||
|
if (gGpgKeyId.empty()) return std::nullopt;
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
const std::filesystem::path dir =
|
||||||
|
std::filesystem::temp_directory_path(ec) / "catcrafts-invoice";
|
||||||
|
if (ec) return std::nullopt;
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
std::filesystem::permissions(dir, std::filesystem::perms::owner_all, ec);
|
||||||
|
|
||||||
|
// Distinct per call so concurrent downloads cannot collide.
|
||||||
|
static std::atomic<std::uint64_t> counter{1};
|
||||||
|
const std::uint64_t n = counter.fetch_add(1);
|
||||||
|
const std::filesystem::path in = dir / std::format("in-{}.md", n);
|
||||||
|
const std::filesystem::path out = dir / std::format("out-{}.md.asc", n);
|
||||||
|
|
||||||
|
{
|
||||||
|
std::ofstream f(in, std::ios::trunc | std::ios::binary);
|
||||||
|
if (!f) return std::nullopt;
|
||||||
|
f << markdown;
|
||||||
|
if (!f.flush()) return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --batch: never prompt (the service has no terminal). The key must be
|
||||||
|
// passphrase-free or preset in the agent — deploy/README.md covers it.
|
||||||
|
const std::string cmd = std::format(
|
||||||
|
"gpg --batch --yes --clearsign --local-user '{}' -o '{}' '{}' 2>/dev/null",
|
||||||
|
gGpgKeyId, out.string(), in.string());
|
||||||
|
const int rc = std::system(cmd.c_str());
|
||||||
|
|
||||||
|
std::string signedText;
|
||||||
|
if (rc == 0) {
|
||||||
|
std::ifstream f(out, std::ios::binary);
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << f.rdbuf();
|
||||||
|
signedText = buf.str();
|
||||||
|
} else {
|
||||||
|
std::println(std::cerr, "invoice: gpg clearsign failed (rc {})", rc);
|
||||||
|
}
|
||||||
|
std::filesystem::remove(in, ec);
|
||||||
|
std::filesystem::remove(out, ec);
|
||||||
|
|
||||||
|
if (signedText.empty()) return std::nullopt;
|
||||||
|
return signedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The Mollie payment rail.
|
||||||
|
//
|
||||||
|
// Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on
|
||||||
|
// cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not
|
||||||
|
// a checkout). Mollie is a Dutch licensed PSP built for exactly this size of
|
||||||
|
// shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a
|
||||||
|
// hosted checkout so card data never touches this server.
|
||||||
|
//
|
||||||
|
// The API is refreshingly small next to bunq's: one bearer-token key, no
|
||||||
|
// RSA signing, no session dance.
|
||||||
|
//
|
||||||
|
// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
|
||||||
|
// GET /v2/payments/{id} -> status, method
|
||||||
|
//
|
||||||
|
// Trust direction is unchanged from the design rule: the ?redirect back to
|
||||||
|
// the order page is ignored; an order becomes paid ONLY when an authenticated
|
||||||
|
// GET says status=paid with a covering amount. One deliberate difference from
|
||||||
|
// the bunq tab model: a Mollie payment can EXPIRE (canceled/expired/failed are
|
||||||
|
// terminal), so the poll distinguishes Pending / Paid / Dead and the
|
||||||
|
// reconciler lapses orders whose payment can never arrive.
|
||||||
|
//
|
||||||
|
// A test API key (test_…) works against the real endpoints from the moment a
|
||||||
|
// Mollie account is created — verify with that before going live; unlike the
|
||||||
|
// bunq client this one need not ship on faith.
|
||||||
|
|
||||||
|
module;
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
import Crafter.Network;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string JsonEscapeM(std::string_view s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 8);
|
||||||
|
for (const char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||||
|
|
||||||
|
MolliePayment p;
|
||||||
|
p.id = std::string(doc->Str("id"));
|
||||||
|
p.status = std::string(doc->Str("status"));
|
||||||
|
p.method = std::string(doc->Str("method"));
|
||||||
|
if (p.id.empty() || p.status.empty()) return std::nullopt;
|
||||||
|
|
||||||
|
if (const Json::Value* amount = doc->Find("amount"); amount && amount->IsObject()) {
|
||||||
|
// Only euro amounts are ever created, so anything else failing to
|
||||||
|
// parse to zero is the safe outcome — a zero amount never satisfies
|
||||||
|
// an order total.
|
||||||
|
if (amount->Str("currency") == "EUR") {
|
||||||
|
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||||
|
p.amountMinor = *minor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (const Json::Value* links = doc->Find("_links"); links && links->IsObject()) {
|
||||||
|
if (const Json::Value* checkout = links->Find("checkout");
|
||||||
|
checkout && checkout->IsObject()) {
|
||||||
|
p.checkoutUrl = std::string(checkout->Str("href"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class MollieRail final : public PaymentRail {
|
||||||
|
public:
|
||||||
|
explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
|
||||||
|
|
||||||
|
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||||
|
const std::string& description,
|
||||||
|
const std::string& redirectUrl) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
const std::string body = std::format(
|
||||||
|
R"({{"amount":{{"currency":"EUR","value":"{}"}},)"
|
||||||
|
R"("description":"{}","redirectUrl":"{}"}})",
|
||||||
|
Money::FormatMinor(amountMinor), JsonEscapeM(description),
|
||||||
|
JsonEscapeM(redirectUrl));
|
||||||
|
|
||||||
|
const std::optional<std::string> res = Call("POST", "/v2/payments", body);
|
||||||
|
if (!res) return std::nullopt;
|
||||||
|
const auto payment = ParseMolliePayment(*res);
|
||||||
|
if (!payment || payment->checkoutUrl.empty()) {
|
||||||
|
std::println(std::cerr, "mollie: create returned no checkout url");
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
PaymentLink link;
|
||||||
|
link.payId = payment->id;
|
||||||
|
link.payUrl = payment->checkoutUrl;
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||||
|
std::int64_t expectedMinor) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
// The id came from Mollie, but it travels through our ledger — keep
|
||||||
|
// the path composition strict anyway.
|
||||||
|
for (const char c : payId) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
|| (c >= '0' && c <= '9') || c == '_';
|
||||||
|
if (!ok) return PaidStatus{ PayState::Dead, {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<std::string> res = Call("GET", "/v2/payments/" + payId, {});
|
||||||
|
if (!res) return std::nullopt;
|
||||||
|
const auto payment = ParseMolliePayment(*res);
|
||||||
|
if (!payment) return std::nullopt;
|
||||||
|
|
||||||
|
PaidStatus out;
|
||||||
|
out.method = payment->method;
|
||||||
|
if (payment->status == "paid" && payment->amountMinor >= expectedMinor) {
|
||||||
|
out.state = PayState::Paid;
|
||||||
|
} else if (payment->status == "canceled" || payment->status == "expired"
|
||||||
|
|| payment->status == "failed") {
|
||||||
|
out.state = PayState::Dead;
|
||||||
|
} else {
|
||||||
|
// open / pending / authorized — still in flight.
|
||||||
|
out.state = PayState::Pending;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Name() const override { return "mollie"; }
|
||||||
|
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(10); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
|
||||||
|
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
|
||||||
|
std::optional<std::string> Call(std::string_view method, const std::string& path,
|
||||||
|
const std::string& body) {
|
||||||
|
try {
|
||||||
|
if (!client_) {
|
||||||
|
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||||
|
"api.mollie.com", static_cast<std::uint16_t>(443),
|
||||||
|
Crafter::TLSClientCredentials{});
|
||||||
|
}
|
||||||
|
Crafter::HTTPRequest req;
|
||||||
|
req.method = std::string(method);
|
||||||
|
req.path = path;
|
||||||
|
req.authority = "api.mollie.com";
|
||||||
|
req.body = body;
|
||||||
|
req.headers["authorization"] = "Bearer " + cfg_.apiKey;
|
||||||
|
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||||
|
if (!body.empty()) req.headers["content-type"] = "application/json";
|
||||||
|
|
||||||
|
const Crafter::HTTPResponse res = client_->Send(req);
|
||||||
|
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||||
|
std::println(std::cerr, "mollie: {} {} -> {} {}", method, path,
|
||||||
|
res.status, res.body.substr(0, 200));
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return res.body;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "mollie: {} {} failed: {}", method, path, e.what());
|
||||||
|
client_.reset(); // dial fresh next time
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RailConfig cfg_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Defined here rather than in the bunq unit so the rail roster has one home;
|
||||||
|
// the bunq and fake constructors are declared by their own units.
|
||||||
|
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config);
|
||||||
|
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config);
|
||||||
|
|
||||||
|
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
|
||||||
|
if (config.mode == "fake") return MakeFakeRail(config);
|
||||||
|
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
|
||||||
|
if (config.mode == "bunq") return MakeBunqRail(config);
|
||||||
|
return nullptr; // "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
301
server/implementations/Catcrafts.Server-Orders.cpp
Normal file
301
server/implementations/Catcrafts.Server-Orders.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Order storage: an append-only JSON-lines event log.
|
||||||
|
//
|
||||||
|
// Two event types share the file:
|
||||||
|
//
|
||||||
|
// {"type":"order", ...full record...} written once, at checkout
|
||||||
|
// {"type":"status", "id":..,"status":..} one per transition
|
||||||
|
//
|
||||||
|
// Current state is a left fold over the file; later events win. Nothing is
|
||||||
|
// ever rewritten, so the log doubles as the audit trail the tax records need,
|
||||||
|
// and a crash mid-write costs at most its own line (a truncated last line is
|
||||||
|
// skipped by the reader, not fatal).
|
||||||
|
//
|
||||||
|
// Why not SQLite yet: single-digit orders per week, one writer, no relations.
|
||||||
|
// The day volume proves that wrong, this imports into a database in one
|
||||||
|
// sitting. What this file holds is personal data (name, address, email), so
|
||||||
|
// the same rules as ever: 0600 via the service's umask, off the web root,
|
||||||
|
// encrypted before any backup leaves the machine.
|
||||||
|
|
||||||
|
module;
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::mutex gOrdersMutex;
|
||||||
|
std::filesystem::path gOrdersPath;
|
||||||
|
|
||||||
|
// Minimal JSON string escaping. Values were validated upstream, but they are
|
||||||
|
// still user input, and a raw newline or quote would corrupt the
|
||||||
|
// line-per-record format — silently truncating the data on the next read.
|
||||||
|
std::string JsonEscape(std::string_view s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 8);
|
||||||
|
for (const char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppendLine(const std::string& line) {
|
||||||
|
if (gOrdersPath.empty()) return false;
|
||||||
|
// Open per append: orders arrive rarely, and a file reopened each time can
|
||||||
|
// be rotated or edited underneath the running process without a restart.
|
||||||
|
std::ofstream out(gOrdersPath, std::ios::app | std::ios::binary);
|
||||||
|
if (!out) return false;
|
||||||
|
out << line << '\n';
|
||||||
|
out.flush();
|
||||||
|
// Report the stream state: a full disk must surface as a visible error,
|
||||||
|
// not a payment link over an order that was never recorded.
|
||||||
|
return static_cast<bool>(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the whole log into id -> record. Corrupt lines are skipped — one bad
|
||||||
|
// line must not take the rest of the ledger with it.
|
||||||
|
std::vector<OrderRecord> FoldLocked() {
|
||||||
|
std::vector<OrderRecord> out;
|
||||||
|
if (gOrdersPath.empty()) return out;
|
||||||
|
std::ifstream in(gOrdersPath, std::ios::binary);
|
||||||
|
if (!in) return out;
|
||||||
|
|
||||||
|
auto find = [&](std::string_view token) -> OrderRecord* {
|
||||||
|
for (OrderRecord& r : out) {
|
||||||
|
if (r.token == token) return &r;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
auto doc = Json::Parse(line);
|
||||||
|
if (!doc || !doc->IsObject()) continue;
|
||||||
|
const std::string_view type = doc->Str("type");
|
||||||
|
if (type == "order") {
|
||||||
|
OrderRecord r;
|
||||||
|
r.token = std::string(doc->Str("id"));
|
||||||
|
r.reference = std::string(doc->Str("ref"));
|
||||||
|
r.product = std::string(doc->Str("product"));
|
||||||
|
r.color = std::string(doc->Str("color"));
|
||||||
|
r.quantity = doc->Int("quantity", 1);
|
||||||
|
r.unitMinor = doc->Int("unit_minor");
|
||||||
|
r.createdAt = std::string(doc->Str("at"));
|
||||||
|
r.updatedAt = r.createdAt;
|
||||||
|
r.buyer.email = std::string(doc->Str("email"));
|
||||||
|
r.buyer.name = std::string(doc->Str("name"));
|
||||||
|
r.buyer.street = std::string(doc->Str("street"));
|
||||||
|
r.buyer.postal = std::string(doc->Str("postal"));
|
||||||
|
r.buyer.city = std::string(doc->Str("city"));
|
||||||
|
r.buyer.country = std::string(doc->Str("country"));
|
||||||
|
r.goodsMinor = doc->Int("goods_minor");
|
||||||
|
r.shippingMinor = doc->Int("shipping_minor");
|
||||||
|
r.totalMinor = doc->Int("total_minor");
|
||||||
|
r.vatIncluded = doc->Bool("vat_included");
|
||||||
|
r.status = std::string(doc->Str("status", "awaiting_payment"));
|
||||||
|
r.payUrl = std::string(doc->Str("pay_url"));
|
||||||
|
r.payId = std::string(doc->Str("pay_id"));
|
||||||
|
if (r.token.empty()) continue;
|
||||||
|
// A duplicate "order" event for an id would be a writer bug; first
|
||||||
|
// one wins so a replayed line cannot rewrite history.
|
||||||
|
if (!find(r.token)) out.push_back(std::move(r));
|
||||||
|
} else if (type == "invoice") {
|
||||||
|
OrderRecord* r = find(doc->Str("id"));
|
||||||
|
if (!r) continue;
|
||||||
|
r->invoiceNumber = std::string(doc->Str("number"));
|
||||||
|
r->invoicedAt = std::string(doc->Str("at"));
|
||||||
|
} else if (type == "status") {
|
||||||
|
OrderRecord* r = find(doc->Str("id"));
|
||||||
|
if (!r) continue; // status for an unknown order: skip, keep folding
|
||||||
|
const std::string_view status = doc->Str("status");
|
||||||
|
if (status.empty()) continue;
|
||||||
|
r->status = std::string(status);
|
||||||
|
r->updatedAt = std::string(doc->Str("at"));
|
||||||
|
if (const std::string_view via = doc->Str("via"); !via.empty()) {
|
||||||
|
r->paidVia = std::string(via);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void SetOrdersPath(const std::filesystem::path& p) {
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
gOrdersPath = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CreateOrder(const OrderRecord& o) {
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
return AppendLine(std::format(
|
||||||
|
R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)"
|
||||||
|
R"("color":"{}","quantity":{},"unit_minor":{},)"
|
||||||
|
R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
|
||||||
|
R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},)"
|
||||||
|
R"("status":"{}","pay_url":"{}","pay_id":"{}"}})",
|
||||||
|
JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference),
|
||||||
|
JsonEscape(o.product),
|
||||||
|
JsonEscape(o.color), o.quantity, o.unitMinor,
|
||||||
|
JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street),
|
||||||
|
JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country),
|
||||||
|
o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded,
|
||||||
|
JsonEscape(o.status), JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||||
|
std::string_view isoTimestamp, std::string_view via) {
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
if (via.empty()) {
|
||||||
|
return AppendLine(std::format(
|
||||||
|
R"({{"type":"status","at":"{}","id":"{}","status":"{}"}})",
|
||||||
|
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status)));
|
||||||
|
}
|
||||||
|
return AppendLine(std::format(
|
||||||
|
R"({{"type":"status","at":"{}","id":"{}","status":"{}","via":"{}"}})",
|
||||||
|
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status),
|
||||||
|
JsonEscape(via)));
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Case-normalised email: the customer key. Good enough on purpose — a person
|
||||||
|
// with two addresses is two customers, exactly as they would be in the manual
|
||||||
|
// administration this scheme continues.
|
||||||
|
std::string CustomerKey(std::string_view email) {
|
||||||
|
std::string out(email);
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A random v4 UUID — the customer number, matching the pre-shop invoice
|
||||||
|
// administration (folders named by customer UUID, invoices <uuid>-<n>).
|
||||||
|
std::string NewCustomerUuid() {
|
||||||
|
std::random_device rd;
|
||||||
|
std::array<std::uint32_t, 4> w{ rd(), rd(), rd(), rd() };
|
||||||
|
auto* b = reinterpret_cast<unsigned char*>(w.data());
|
||||||
|
b[6] = static_cast<unsigned char>((b[6] & 0x0f) | 0x40); // version 4
|
||||||
|
b[8] = static_cast<unsigned char>((b[8] & 0x3f) | 0x80); // variant 10
|
||||||
|
std::string out;
|
||||||
|
out.reserve(36);
|
||||||
|
static constexpr char hex[] = "0123456789abcdef";
|
||||||
|
for (int i = 0; i < 16; ++i) {
|
||||||
|
if (i == 4 || i == 6 || i == 8 || i == 10) out += '-';
|
||||||
|
out += hex[b[i] >> 4];
|
||||||
|
out += hex[b[i] & 0xf];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
||||||
|
std::string_view isoTimestamp) {
|
||||||
|
// Numbering continues the shop owner's existing administration: one
|
||||||
|
// SERIES PER CUSTOMER (a random UUID as customer number), sequential
|
||||||
|
// within it — "f57c6512-…-3" is that customer's third invoice. Multiple
|
||||||
|
// series are what art. 226(2)'s "one or more series" permits, and the
|
||||||
|
// append-only ledger plus the payment provider's records carry the
|
||||||
|
// completeness proof an auditor actually wants.
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
const OrderRecord* target = nullptr;
|
||||||
|
std::vector<OrderRecord> all = FoldLocked();
|
||||||
|
for (const OrderRecord& r : all) {
|
||||||
|
if (r.token == token) { target = &r; break; }
|
||||||
|
}
|
||||||
|
if (!target) return std::nullopt;
|
||||||
|
// Idempotent: a paid order re-processed (manual CLI after the reconciler,
|
||||||
|
// say) keeps its number — a sequence never burns a member on a retry.
|
||||||
|
if (!target->invoiceNumber.empty()) return target->invoiceNumber;
|
||||||
|
|
||||||
|
// The customer's existing series, if any: same email (case-normalised),
|
||||||
|
// highest sequence. Invoice numbers are "<uuid(36)>-<seq>".
|
||||||
|
const std::string key = CustomerKey(target->buyer.email);
|
||||||
|
std::string customer;
|
||||||
|
std::int64_t maxSeq = 0;
|
||||||
|
for (const OrderRecord& r : all) {
|
||||||
|
if (r.invoiceNumber.size() < 38 || CustomerKey(r.buyer.email) != key) continue;
|
||||||
|
customer = r.invoiceNumber.substr(0, 36);
|
||||||
|
std::int64_t seq = 0;
|
||||||
|
const char* b = r.invoiceNumber.data() + 37;
|
||||||
|
std::from_chars(b, r.invoiceNumber.data() + r.invoiceNumber.size(), seq);
|
||||||
|
maxSeq = std::max(maxSeq, seq);
|
||||||
|
}
|
||||||
|
if (customer.empty()) customer = NewCustomerUuid();
|
||||||
|
|
||||||
|
const std::string number = std::format("{}-{}", customer, maxSeq + 1);
|
||||||
|
if (!AppendLine(std::format(
|
||||||
|
R"({{"type":"invoice","at":"{}","id":"{}","number":"{}","customer":"{}"}})",
|
||||||
|
JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(number),
|
||||||
|
JsonEscape(customer)))) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<OrderRecord> FindOrder(std::string_view token) {
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
for (OrderRecord& r : FoldLocked()) {
|
||||||
|
if (r.token == token) return std::move(r);
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<OrderRecord> ListOrders() {
|
||||||
|
std::lock_guard lock(gOrdersMutex);
|
||||||
|
return FoldLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string NewOrderToken() {
|
||||||
|
// std::random_device on this platform reads the kernel CSPRNG. The token
|
||||||
|
// gates access to a name and address, so 128 bits — the same order of
|
||||||
|
// unguessability as a session cookie.
|
||||||
|
std::random_device rd;
|
||||||
|
std::string out;
|
||||||
|
out.reserve(32);
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
const std::uint32_t w = rd();
|
||||||
|
out += std::format("{:08x}", w);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReferenceFromToken(std::string_view token) {
|
||||||
|
// Derived, not random: an order can never carry a mismatched pair. Six hex
|
||||||
|
// chars is what a human will actually type into a transfer description; at
|
||||||
|
// this volume a collision is a curiosity, and the amount+time still
|
||||||
|
// disambiguate at reconciliation.
|
||||||
|
std::string out = "CC-";
|
||||||
|
for (std::size_t i = 0; i < 6 && i < token.size(); ++i) {
|
||||||
|
char c = token[i];
|
||||||
|
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Live shipping rates from Sendcloud, with the zone table as the floor.
|
||||||
|
//
|
||||||
|
// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
|
||||||
|
// account can book, each with a per-country price list. One configured method
|
||||||
|
// (matched by name substring) becomes a country -> cents table, cached to disk
|
||||||
|
// and refreshed daily by a background thread the HTTP layer starts.
|
||||||
|
//
|
||||||
|
// Failure posture mirrors the rest of the build pipeline: Sendcloud being
|
||||||
|
// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
|
||||||
|
// table (Catcrafts.Shared:Content) answers instead. A stale cached table
|
||||||
|
// beats both, which is why the cache survives restarts.
|
||||||
|
//
|
||||||
|
// Like the bunq rail, this code has not run against the real API — no
|
||||||
|
// credentials existed at build time. ParseSendcloudMethods is exercised by the
|
||||||
|
// self-test against a canned response; the fetch around it is thin.
|
||||||
|
|
||||||
|
module;
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
import Crafter.Network;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::mutex gShipMutex;
|
||||||
|
ShippingConfig gShipConfig;
|
||||||
|
ShippingTable gShipTable;
|
||||||
|
bool gShipConfigured = false;
|
||||||
|
|
||||||
|
// Same alphabet as the bunq helper; duplicated rather than shared because
|
||||||
|
// each implementation unit keeps its internals to itself.
|
||||||
|
std::string Base64S(std::string_view in) {
|
||||||
|
static constexpr char tbl[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string out;
|
||||||
|
std::size_t i = 0;
|
||||||
|
const auto* d = reinterpret_cast<const unsigned char*>(in.data());
|
||||||
|
for (; i + 2 < in.size(); i += 3) {
|
||||||
|
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||||
|
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||||
|
}
|
||||||
|
if (i + 1 == in.size()) {
|
||||||
|
const std::uint32_t n = d[i] << 16;
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63]; out += "==";
|
||||||
|
} else if (i + 2 == in.size()) {
|
||||||
|
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8);
|
||||||
|
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||||
|
out += tbl[(n >> 6) & 63]; out += '=';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string NowIsoS() {
|
||||||
|
return std::format("{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
||||||
|
std::chrono::system_clock::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SaveCacheLocked() {
|
||||||
|
if (gShipConfig.cachePath.empty()) return;
|
||||||
|
std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
|
||||||
|
if (!out) return;
|
||||||
|
out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
|
||||||
|
gShipTable.method, gShipTable.fetchedAt);
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& [cc, minor] : gShipTable.perCountry) {
|
||||||
|
out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
out << "}}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadCacheLocked() {
|
||||||
|
std::ifstream in(gShipConfig.cachePath, std::ios::binary);
|
||||||
|
if (!in) return;
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
auto doc = Json::Parse(buf.str());
|
||||||
|
if (!doc || !doc->IsObject()) return;
|
||||||
|
ShippingTable t;
|
||||||
|
t.method = std::string(doc->Str("method"));
|
||||||
|
t.fetchedAt = std::string(doc->Str("fetched_at"));
|
||||||
|
if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
|
||||||
|
for (const auto& [k, v] : m->object) {
|
||||||
|
if (v.type == Json::Type::Number && v.number > 0) {
|
||||||
|
t.perCountry.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!t.perCountry.empty()) gShipTable = std::move(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// `methodName` is a comma-separated list of name substrings, merged in order
|
||||||
|
// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
|
||||||
|
// market: the realistic setup is a courier inside Europe and post beyond
|
||||||
|
// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
|
||||||
|
// a country served by both gets the earlier method's price.
|
||||||
|
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
|
||||||
|
ShippingTable out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return out;
|
||||||
|
const Json::Value* methods = doc->Find("shipping_methods");
|
||||||
|
if (!methods || !methods->IsArray()) return out;
|
||||||
|
|
||||||
|
std::vector<std::string_view> filters;
|
||||||
|
{
|
||||||
|
std::string_view rest = methodName;
|
||||||
|
while (!rest.empty()) {
|
||||||
|
const std::size_t comma = rest.find(',');
|
||||||
|
std::string_view part = rest.substr(0, comma);
|
||||||
|
while (!part.empty() && part.front() == ' ') part.remove_prefix(1);
|
||||||
|
while (!part.empty() && part.back() == ' ') part.remove_suffix(1);
|
||||||
|
if (!part.empty()) filters.push_back(part);
|
||||||
|
if (comma == std::string_view::npos) break;
|
||||||
|
rest = rest.substr(comma + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const std::string_view filter : filters) {
|
||||||
|
for (const Json::Value& method : methods->array) {
|
||||||
|
if (!method.IsObject()) continue;
|
||||||
|
const std::string_view name = method.Str("name");
|
||||||
|
if (name.find(filter) == std::string_view::npos) continue;
|
||||||
|
|
||||||
|
if (!out.method.empty()) out.method += " + ";
|
||||||
|
out.method += std::string(name);
|
||||||
|
if (const Json::Value* countries = method.Find("countries");
|
||||||
|
countries && countries->IsArray()) {
|
||||||
|
for (const Json::Value& c : countries->array) {
|
||||||
|
if (!c.IsObject()) continue;
|
||||||
|
std::string cc(c.Str("iso_2"));
|
||||||
|
if (cc.size() != 2) continue;
|
||||||
|
// Earlier methods own their countries — a later method
|
||||||
|
// never overrides.
|
||||||
|
if (out.Find(cc) > 0) continue;
|
||||||
|
// Sendcloud sends the price as a JSON number of euros.
|
||||||
|
// Money stays integer everywhere else; this one boundary
|
||||||
|
// rounds a decimal that is exact to the cent in a double
|
||||||
|
// (shipping prices are far inside the safe range), and
|
||||||
|
// llround guards the representation edge
|
||||||
|
// (8.20*100 == 819.999...).
|
||||||
|
const Json::Value* price = c.Find("price");
|
||||||
|
if (!price || price->type != Json::Type::Number) continue;
|
||||||
|
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||||
|
if (minor <= 0) continue;
|
||||||
|
out.perCountry.emplace_back(std::move(cc), minor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break; // first method matching THIS filter wins; next filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigureShipping(const ShippingConfig& config) {
|
||||||
|
std::lock_guard lock(gShipMutex);
|
||||||
|
gShipConfig = config;
|
||||||
|
gShipConfigured = !config.publicKey.empty() && !config.secretKey.empty()
|
||||||
|
&& !config.methodName.empty();
|
||||||
|
LoadCacheLocked();
|
||||||
|
if (gShipConfigured) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"shipping: sendcloud configured (method filter '{}'){}",
|
||||||
|
config.methodName,
|
||||||
|
gShipTable.perCountry.empty()
|
||||||
|
? ""
|
||||||
|
: std::format(", cached table: {} countries from {}",
|
||||||
|
gShipTable.perCountry.size(),
|
||||||
|
gShipTable.fetchedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||||
|
std::int64_t zoneEu, std::int64_t zoneWorld) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(gShipMutex);
|
||||||
|
if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
|
||||||
|
}
|
||||||
|
return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
|
||||||
|
}
|
||||||
|
|
||||||
|
ShippingTable CurrentShippingTable() {
|
||||||
|
std::lock_guard lock(gShipMutex);
|
||||||
|
return gShipTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||||
|
// failure the previous table (cached or zone fallback) simply stays.
|
||||||
|
void RefreshShippingTable() {
|
||||||
|
ShippingConfig cfg;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(gShipMutex);
|
||||||
|
if (!gShipConfigured) return;
|
||||||
|
cfg = gShipConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Crafter::ClientHTTP1 client("panel.sendcloud.sc",
|
||||||
|
static_cast<std::uint16_t>(443),
|
||||||
|
Crafter::TLSClientCredentials{});
|
||||||
|
Crafter::HTTPRequest req;
|
||||||
|
req.method = "GET";
|
||||||
|
req.path = "/api/v2/shipping_methods";
|
||||||
|
req.authority = "panel.sendcloud.sc";
|
||||||
|
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||||
|
req.headers["authorization"] =
|
||||||
|
"Basic " + Base64S(cfg.publicKey + ":" + cfg.secretKey);
|
||||||
|
const Crafter::HTTPResponse res = client.Send(req);
|
||||||
|
if (res.status != "200") {
|
||||||
|
std::println(std::cerr, "shipping: sendcloud answered {}", res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShippingTable t = ParseSendcloudMethods(res.body, cfg.methodName);
|
||||||
|
if (t.perCountry.empty()) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"shipping: no method matching '{}' with prices in the response",
|
||||||
|
cfg.methodName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sendcloud rates are the shop's ex-VAT COST. What the buyer is
|
||||||
|
// charged must NET that cost: EU destinations are grossed up by the
|
||||||
|
// VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
|
||||||
|
// is covered), non-EU postage is zero-rated so cost is charged as-is.
|
||||||
|
// Done once at table build — the cache stores consumer prices, so a
|
||||||
|
// cache reload must not (and does not) gross up again.
|
||||||
|
for (auto& [cc, minor] : t.perCountry) {
|
||||||
|
if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
|
||||||
|
}
|
||||||
|
t.fetchedAt = NowIsoS();
|
||||||
|
{
|
||||||
|
std::lock_guard lock(gShipMutex);
|
||||||
|
gShipTable = std::move(t);
|
||||||
|
SaveCacheLocked();
|
||||||
|
}
|
||||||
|
std::println(std::cerr, "shipping: table refreshed ({} countries, method '{}')",
|
||||||
|
CurrentShippingTable().perCountry.size(),
|
||||||
|
CurrentShippingTable().method);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "shipping: refresh failed: {}", e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
932
server/implementations/main.cpp
Normal file
932
server/implementations/main.cpp
Normal file
|
|
@ -0,0 +1,932 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// catcrafts-server — the native product.
|
||||||
|
//
|
||||||
|
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||||||
|
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||||||
|
// as the test harness for Catcrafts.Shared.
|
||||||
|
//
|
||||||
|
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||||||
|
// for every piece of markup the site emits, and it is target-neutral precisely
|
||||||
|
// so it can be tested somewhere with a debugger, sanitizers and a normal test
|
||||||
|
// loop instead of only inside a wasm module in a browser tab. `--selftest`
|
||||||
|
// is how the shared code gets executed rather than merely compiled.
|
||||||
|
//
|
||||||
|
// crafter-build -- --product=server && ./bin/Catcrafts.Server-*/catcrafts-server --selftest
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
import Catcrafts.Server;
|
||||||
|
|
||||||
|
using namespace Catcrafts;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||||
|
if (ok) return;
|
||||||
|
++failures;
|
||||||
|
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||||
|
got.empty() ? "" : " got: ", got);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||||||
|
Check(actual.View() == expected, what, actual.View());
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunSelfTest() {
|
||||||
|
using namespace Catcrafts::Html;
|
||||||
|
|
||||||
|
// ── Escape ────────────────────────────────────────────────────────
|
||||||
|
CheckEq(Escape("plain"), "plain", "escape: passthrough");
|
||||||
|
CheckEq(Escape("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");
|
||||||
|
}
|
||||||
|
Check(!Content::Projects().empty(), "content: projects present");
|
||||||
|
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||||
|
Check(!Content::Demos().empty(), "content: demos present");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the Sendcloud response parser ─────────────────────────────────
|
||||||
|
{
|
||||||
|
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||||
|
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||||||
|
{"name":"DHL For You Home","countries":[
|
||||||
|
{"iso_2":"NL","price":6.25},
|
||||||
|
{"iso_2":"DE","price":8.20},
|
||||||
|
{"iso_2":"CA","price":42.50},
|
||||||
|
{"iso_2":"XX","price":0},
|
||||||
|
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||||||
|
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||||||
|
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||||||
|
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||||||
|
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||||||
|
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||||||
|
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||||||
|
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||||||
|
"sendcloud: malformed payload yields nothing");
|
||||||
|
|
||||||
|
// Comma-separated merge: courier for Europe, post for the world; the
|
||||||
|
// earlier method keeps any country both cover.
|
||||||
|
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||||
|
{"name":"DPD Home","countries":[
|
||||||
|
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||||||
|
{"name":"PostNL Parcels non-EU","countries":[
|
||||||
|
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||||||
|
{"iso_2":"DE","price":99.99}]}]})",
|
||||||
|
"DPD Home, PostNL Parcels non-EU");
|
||||||
|
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||||||
|
"sendcloud: merged table covers both methods");
|
||||||
|
Check(merged.Find("DE") == 1049,
|
||||||
|
"sendcloud: earlier method wins a shared country");
|
||||||
|
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||||||
|
"sendcloud: merged method names recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── indicative conversion ─────────────────────────────────────────
|
||||||
|
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||||||
|
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||||||
|
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||||||
|
auto ca$ = CurrencyFor("CA");
|
||||||
|
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||||||
|
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||||||
|
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||||||
|
if (ca$) {
|
||||||
|
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── order tokens and references ───────────────────────────────────
|
||||||
|
Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape");
|
||||||
|
Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected");
|
||||||
|
Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected");
|
||||||
|
Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected");
|
||||||
|
const std::string tok = Server::NewOrderToken();
|
||||||
|
Check(IsOrderToken(tok), "token: generator emits valid tokens", tok);
|
||||||
|
Check(Server::NewOrderToken() != tok, "token: not constant");
|
||||||
|
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||||
|
"reference: derived and uppercased");
|
||||||
|
|
||||||
|
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||||||
|
using Server::ParseAmountToMinor;
|
||||||
|
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||||
|
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||||
|
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
|
||||||
|
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
|
||||||
|
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
|
||||||
|
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
|
||||||
|
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
|
||||||
|
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
|
||||||
|
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
|
||||||
|
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
|
||||||
|
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
|
||||||
|
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
|
||||||
|
|
||||||
|
// ── the Mollie payment parser ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
const auto p1 = Server::ParseMolliePayment(R"({
|
||||||
|
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
|
||||||
|
"amount":{"value":"578.30","currency":"EUR"},
|
||||||
|
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
|
||||||
|
Check(p1.has_value(), "mollie: open payment parses");
|
||||||
|
if (p1) {
|
||||||
|
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
|
||||||
|
Check(p1->status == "open", "mollie: status");
|
||||||
|
Check(p1->amountMinor == 57830, "mollie: amount to cents");
|
||||||
|
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
|
||||||
|
"mollie: checkout link");
|
||||||
|
Check(p1->method.empty(), "mollie: null method is empty");
|
||||||
|
}
|
||||||
|
const auto p2 = Server::ParseMolliePayment(R"({
|
||||||
|
"id":"tr_x","status":"paid","method":"ideal",
|
||||||
|
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
|
||||||
|
Check(p2 && p2->status == "paid" && p2->method == "ideal",
|
||||||
|
"mollie: paid payment carries the method");
|
||||||
|
const auto p3 = Server::ParseMolliePayment(R"({
|
||||||
|
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
|
||||||
|
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
|
||||||
|
Check(!Server::ParseMolliePayment("garbage").has_value(),
|
||||||
|
"mollie: malformed payload rejected");
|
||||||
|
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
|
||||||
|
"mollie: missing id rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the invoice builder ───────────────────────────────────────────
|
||||||
|
{
|
||||||
|
Server::OrderRecord o;
|
||||||
|
o.token = "0123456789abcdef0123456789abcdef";
|
||||||
|
o.reference = "CC-TEST01";
|
||||||
|
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
|
||||||
|
o.invoicedAt = "2026-08-05T10:00:00Z";
|
||||||
|
o.createdAt = "2026-08-05T09:55:00Z";
|
||||||
|
o.paidVia = "ideal";
|
||||||
|
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
|
||||||
|
"Delft", "NL" };
|
||||||
|
o.quantity = 2;
|
||||||
|
o.unitMinor = 56330;
|
||||||
|
o.goodsMinor = 112660;
|
||||||
|
o.shippingMinor = 863;
|
||||||
|
o.totalMinor = 113523;
|
||||||
|
o.vatIncluded = true;
|
||||||
|
|
||||||
|
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||||||
|
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
|
||||||
|
"invoice: number heading");
|
||||||
|
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
|
||||||
|
"invoice: customer series shown separately");
|
||||||
|
Check(eu.find("* Invoice number: 7") != std::string::npos,
|
||||||
|
"invoice: sequence within the series");
|
||||||
|
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
|
||||||
|
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
|
||||||
|
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
|
||||||
|
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
|
||||||
|
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
|
||||||
|
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
|
||||||
|
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
|
||||||
|
"invoice: item names the colour");
|
||||||
|
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
|
||||||
|
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
|
||||||
|
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||||
|
|
||||||
|
o.vatIncluded = false;
|
||||||
|
o.buyer.country = "CA";
|
||||||
|
o.goodsMinor = 93107;
|
||||||
|
o.shippingMinor = 2395;
|
||||||
|
o.totalMinor = 95502;
|
||||||
|
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||||||
|
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
|
||||||
|
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
|
||||||
|
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rates loader ──────────────────────────────────────────────────
|
||||||
|
const Rates r = LoadRates(
|
||||||
|
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
|
||||||
|
Check(r.date == "2026-08-04", "rates: date");
|
||||||
|
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
||||||
|
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||||
|
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReadFile(const std::filesystem::path& p) {
|
||||||
|
std::ifstream in(p, std::ios::binary);
|
||||||
|
if (!in) return {};
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
return buf.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load content/ from disk. The wasm host reads the same bytes out of the VFS
|
||||||
|
// instead; the loaders are shared, so only the source of the bytes differs.
|
||||||
|
// Content loader for the CLI modes (--render, --routes, --sitemap, --feed).
|
||||||
|
//
|
||||||
|
// Must stay in step with Server::LoadContent, which the --serve path uses. They
|
||||||
|
// are separate because the CLI wants a value it can pass around while the server
|
||||||
|
// keeps process-wide state — but a field added to one and forgotten in the other
|
||||||
|
// shows up as content silently missing from exactly one code path, which is how
|
||||||
|
// products came to be absent from --routes and --sitemap while the live server
|
||||||
|
// served them fine.
|
||||||
|
Views::SiteContent LoadContent(const std::filesystem::path& root) {
|
||||||
|
Views::SiteContent c;
|
||||||
|
c.projects = Content::Projects();
|
||||||
|
c.products = Content::Products();
|
||||||
|
c.legal = Content::LegalPages();
|
||||||
|
c.demos = Content::Demos();
|
||||||
|
c.posts = LoadPosts(ReadFile(root / "posts.json"));
|
||||||
|
c.rates = LoadRates(ReadFile(root / "rates.json"));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
const std::vector<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");
|
||||||
|
for (std::string_view p : { "/", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||||||
|
"/order/0123456789abcdef0123456789abcdef",
|
||||||
|
"/order/not-a-token",
|
||||||
|
"/legal/privacy", "/legal/imprint",
|
||||||
|
"/legal/terms", "/legal/nope",
|
||||||
|
"/projects", "/posts", "/demos",
|
||||||
|
"/demos/raytracer", "/demos/nope", "/demo",
|
||||||
|
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
|
||||||
|
const Route r = ParseRoute(p);
|
||||||
|
const Views::RenderedPage page = Views::RenderRoute(r, content);
|
||||||
|
std::println("{:<22} status={} bytes={:<6} title={}",
|
||||||
|
p, page.status, page.main.Size(), page.meta.title);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --serve [port] [--content=DIR] [--webroot=DIR]
|
||||||
|
//
|
||||||
|
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
|
||||||
|
// Catcrafts.Server-Http.cpp for why not HTTP/3.
|
||||||
|
//
|
||||||
|
// Both directories are options rather than fixed paths because the
|
||||||
|
// development layout and the deployed layout differ: in the repo the
|
||||||
|
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
|
||||||
|
// while on the server the content is installed next to the binary and the
|
||||||
|
// bundle IS the webroot Caddy serves.
|
||||||
|
if (!args.empty() && args[0] == "--serve") {
|
||||||
|
std::uint16_t port = 8081;
|
||||||
|
std::filesystem::path contentDir = "content";
|
||||||
|
std::filesystem::path webroot;
|
||||||
|
// Default alongside the content in dev; the systemd unit points this at
|
||||||
|
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||||||
|
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||||
|
std::filesystem::path ordersPath = "orders.jsonl";
|
||||||
|
// Payment rail selection. Flags beat environment beats default. The
|
||||||
|
// default is "whichever provider has a key, off otherwise" so a box
|
||||||
|
// with no credentials serves the whole site minus checkout instead of
|
||||||
|
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||||||
|
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||||||
|
// checkout; the client is kept for a possible future account sweep.
|
||||||
|
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||||
|
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||||||
|
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||||||
|
: bunqKey && *bunqKey ? "bunq"
|
||||||
|
: "off";
|
||||||
|
bool bunqSandbox = [] {
|
||||||
|
const char* v = std::getenv("BUNQ_SANDBOX");
|
||||||
|
return v && std::string_view(v) == "1";
|
||||||
|
}();
|
||||||
|
std::filesystem::path railState;
|
||||||
|
std::string redirectBase = [] {
|
||||||
|
const char* v = std::getenv("ORDER_REDIRECT_BASE");
|
||||||
|
return v && *v ? std::string(v) : std::string("https://catcrafts.net");
|
||||||
|
}();
|
||||||
|
|
||||||
|
for (std::size_t i = 1; i < args.size(); ++i) {
|
||||||
|
const std::string_view a = args[i];
|
||||||
|
if (a.starts_with("--content=")) {
|
||||||
|
contentDir = a.substr(10);
|
||||||
|
} else if (a.starts_with("--webroot=")) {
|
||||||
|
webroot = a.substr(10);
|
||||||
|
} else if (a.starts_with("--orders=")) {
|
||||||
|
ordersPath = a.substr(9);
|
||||||
|
} else if (a.starts_with("--rail=")) {
|
||||||
|
railMode = a.substr(7);
|
||||||
|
} else if (a.starts_with("--bunq=")) {
|
||||||
|
railMode = a.substr(7); // legacy alias for --rail=
|
||||||
|
} else if (a.starts_with("--rail-state=")) {
|
||||||
|
railState = a.substr(13);
|
||||||
|
} else if (a.starts_with("--bunq-state=")) {
|
||||||
|
railState = a.substr(13); // legacy alias for --rail-state=
|
||||||
|
} else if (a.starts_with("--redirect-base=")) {
|
||||||
|
redirectBase = a.substr(16);
|
||||||
|
} else {
|
||||||
|
std::uint32_t parsed = 0;
|
||||||
|
if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{}
|
||||||
|
&& parsed > 0 && parsed <= 65535) {
|
||||||
|
port = static_cast<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;
|
||||||
|
}
|
||||||
259
server/interfaces/Catcrafts.Server.cppm
Normal file
259
server/interfaces/Catcrafts.Server.cppm
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The native server: server-rendered pages, order storage, and the bunq
|
||||||
|
// payment rail.
|
||||||
|
//
|
||||||
|
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
||||||
|
// needs — Crafter.Network here, OpenSSL for request signing. The division of
|
||||||
|
// labour is that Shared decides what the markup IS and Server decides how it
|
||||||
|
// reaches a socket, where orders live, and how money moves.
|
||||||
|
|
||||||
|
export module Catcrafts.Server;
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
|
||||||
|
export namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
// Parse content/*.json and lift the wasm bundle's <script> tags.
|
||||||
|
//
|
||||||
|
// `bundleIndexHtml` is the generated index.html from the wasm build; the
|
||||||
|
// script tags are extracted from it verbatim because they carry a
|
||||||
|
// ?v=<buildId> cache buster that changes every build. Pass an empty path to
|
||||||
|
// serve no wasm at all (every page then renders as plain HTML).
|
||||||
|
//
|
||||||
|
// Call before Serve. Content is immutable afterwards: it is generated at
|
||||||
|
// build time, so nothing can change it under a running process.
|
||||||
|
void LoadContent(const std::filesystem::path& contentDir,
|
||||||
|
const std::filesystem::path& bundleIndexHtml);
|
||||||
|
|
||||||
|
std::size_t ContentPostCount();
|
||||||
|
std::size_t ContentProjectCount();
|
||||||
|
std::size_t ContentProductCount();
|
||||||
|
|
||||||
|
// ── orders ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An append-only JSON-lines EVENT LOG, not a database. Two event types:
|
||||||
|
// "order" (the full record, written once) and "status" (a transition).
|
||||||
|
// Current state is a fold over the file — later events win. Nothing is
|
||||||
|
// ever rewritten in place, so the file is also the audit trail, and a
|
||||||
|
// crash mid-append costs at most the line being written.
|
||||||
|
//
|
||||||
|
// The volume argument: this sells single-digit units per week. When that
|
||||||
|
// is wrong by two orders of magnitude, the log imports into SQLite in one
|
||||||
|
// sitting — the reverse migration would not be so kind.
|
||||||
|
|
||||||
|
struct OrderRecord {
|
||||||
|
std::string token; // 32-hex capability; the /order/<token> URL
|
||||||
|
std::string reference; // "CC-XXXXXX", quoted in bank transfers
|
||||||
|
std::string product; // product slug
|
||||||
|
std::string color; // variant slug ("green"), empty pre-variants
|
||||||
|
std::int64_t quantity = 1;
|
||||||
|
std::int64_t unitMinor = 0; // per-unit gross at order time — prices
|
||||||
|
// change; the record must not
|
||||||
|
std::string createdAt; // ISO 8601 UTC
|
||||||
|
std::string updatedAt; // of the newest event folded in
|
||||||
|
Form::Checkout buyer;
|
||||||
|
std::int64_t goodsMinor = 0;
|
||||||
|
std::int64_t shippingMinor = 0;
|
||||||
|
std::int64_t totalMinor = 0;
|
||||||
|
bool vatIncluded = false;
|
||||||
|
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
||||||
|
std::string payUrl; // the provider's hosted checkout link
|
||||||
|
std::string payId; // provider payment id ("tr_…" at Mollie)
|
||||||
|
std::string paidVia; // method that settled it ("ideal", "creditcard")
|
||||||
|
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||||
|
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||||
|
};
|
||||||
|
|
||||||
|
void SetOrdersPath(const std::filesystem::path& path);
|
||||||
|
bool CreateOrder(const OrderRecord& order);
|
||||||
|
// Appends a status event. Never mutates prior lines; the fold applies it.
|
||||||
|
// `via` records HOW a payment settled ("ideal", "creditcard") on the paid
|
||||||
|
// transition — card money stays reversible for months, so the ledger must
|
||||||
|
// show at a glance which orders carry that tail risk.
|
||||||
|
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||||
|
std::string_view isoTimestamp,
|
||||||
|
std::string_view via = {});
|
||||||
|
std::optional<OrderRecord> FindOrder(std::string_view token);
|
||||||
|
std::vector<OrderRecord> ListOrders();
|
||||||
|
|
||||||
|
// Assigns the next invoice number in the CUSTOMER's series and appends
|
||||||
|
// the invoice event. The scheme continues the owner's pre-shop
|
||||||
|
// administration: customer number is a random UUID, invoices count
|
||||||
|
// sequentially within it ("f57c6512-…-3"). Art. 226(2) permits "one or
|
||||||
|
// more series"; per-customer is the established practice here, and the
|
||||||
|
// ledger + payment-provider records carry the completeness proof.
|
||||||
|
// Idempotent: an order that already has a number keeps it. Fold and
|
||||||
|
// append happen under one lock, so two paid transitions cannot race the
|
||||||
|
// same number.
|
||||||
|
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
||||||
|
std::string_view isoTimestamp);
|
||||||
|
|
||||||
|
// ── invoices ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A paid order's invoice: plain markdown, clearsigned with the shop's
|
||||||
|
// GPG key so its authenticity outlives this server. The page invites the
|
||||||
|
// buyer to download it rather than promising to host receipts forever.
|
||||||
|
|
||||||
|
// Pure and exported for the self-test: everything on a Dutch invoice —
|
||||||
|
// seller identity (KVK/VAT), sequential number, dates, buyer address,
|
||||||
|
// per-line amounts, VAT treatment for EU and export.
|
||||||
|
std::string BuildInvoiceMarkdown(const OrderRecord& order,
|
||||||
|
std::string_view productName,
|
||||||
|
std::string_view colorLabel);
|
||||||
|
|
||||||
|
// The GPG key (uid or fingerprint) invoices are clearsigned with; empty
|
||||||
|
// disables signing and invoices carry an UNSIGNED marker instead —
|
||||||
|
// honest in dev, wrong in production.
|
||||||
|
void ConfigureInvoicing(std::string gpgKeyId);
|
||||||
|
|
||||||
|
// Clearsign via the gpg binary (GNUPGHOME decides the keyring). nullopt
|
||||||
|
// when signing is configured but fails — the caller must NOT serve an
|
||||||
|
// unsigned invoice in that case.
|
||||||
|
std::optional<std::string> ClearsignInvoice(const std::string& markdown);
|
||||||
|
bool InvoiceSigningConfigured();
|
||||||
|
|
||||||
|
// 128 bits of CSPRNG entropy as 32 lowercase hex — the whole capability to
|
||||||
|
// read one order. And its human-sized companion, derived (not random) so a
|
||||||
|
// record can never carry a mismatched pair.
|
||||||
|
std::string NewOrderToken();
|
||||||
|
std::string ReferenceFromToken(std::string_view token);
|
||||||
|
|
||||||
|
// ── payments ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
||||||
|
// answers "has it been paid?". Everything else — storage, rendering,
|
||||||
|
// reconciling — is rail-agnostic, which is what will let a crypto rail
|
||||||
|
// slot in later without reshaping orders.
|
||||||
|
|
||||||
|
struct PaymentLink {
|
||||||
|
std::string payUrl;
|
||||||
|
std::string payId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a poll learned about one payment. Pending and Dead are different
|
||||||
|
// answers on purpose: a Mollie payment EXPIRES (unlike a bunq tab), and an
|
||||||
|
// order whose payment can never arrive should lapse rather than sit
|
||||||
|
// "awaiting" forever.
|
||||||
|
enum class PayState { Pending, Paid, Dead };
|
||||||
|
struct PaidStatus {
|
||||||
|
PayState state = PayState::Pending;
|
||||||
|
std::string method; // "ideal" | "creditcard" | "banktransfer" | …
|
||||||
|
};
|
||||||
|
|
||||||
|
class PaymentRail {
|
||||||
|
public:
|
||||||
|
virtual ~PaymentRail() = default;
|
||||||
|
// nullopt = the provider could not be reached / refused. The checkout
|
||||||
|
// surfaces that honestly instead of creating an unpayable order.
|
||||||
|
virtual std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||||
|
const std::string& description,
|
||||||
|
const std::string& redirectUrl) = 0;
|
||||||
|
// nullopt = could not determine (network, auth) — retry later. Never
|
||||||
|
// guess Dead from a transport error: only the provider saying
|
||||||
|
// expired/canceled/failed kills an order.
|
||||||
|
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||||
|
std::int64_t expectedMinor) = 0;
|
||||||
|
virtual std::string_view Name() const = 0;
|
||||||
|
// How often the reconciler sweeps. The fake rail returns something
|
||||||
|
// tiny so tests are fast; the real providers get a respectful cadence.
|
||||||
|
virtual std::chrono::seconds PollInterval() const = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RailConfig {
|
||||||
|
std::string mode; // "off" | "fake" | "mollie" | "bunq"
|
||||||
|
std::string apiKey; // mollie: live_… or test_…; bunq: its key
|
||||||
|
bool sandbox = false; // bunq only: public-api.sandbox.bunq.com
|
||||||
|
std::filesystem::path statePath; // bunq: session context; fake: paid marker
|
||||||
|
std::string redirectBase = "https://catcrafts.net";
|
||||||
|
};
|
||||||
|
|
||||||
|
// nullptr for mode "off" — the shop then renders but refuses checkout.
|
||||||
|
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
||||||
|
|
||||||
|
// Parsed essentials of a Mollie /v2/payments object. Exported so the
|
||||||
|
// self-test can drive the parser with canned responses — the HTTP around
|
||||||
|
// it is thin.
|
||||||
|
struct MolliePayment {
|
||||||
|
std::string id;
|
||||||
|
std::string status; // open|pending|authorized|paid|canceled|expired|failed
|
||||||
|
std::string method; // may be empty until the payer picks one
|
||||||
|
std::string checkoutUrl; // present while payable
|
||||||
|
std::int64_t amountMinor = 0;
|
||||||
|
};
|
||||||
|
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
||||||
|
|
||||||
|
// Exact decimal-string-to-minor-units parser for amounts coming back from
|
||||||
|
// the bunq API ("614.00" -> 61400). Rejects anything that is not a plain
|
||||||
|
// non-negative decimal with at most two fraction digits — no floats touch
|
||||||
|
// money on the way in either. Exported for the self-test.
|
||||||
|
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
||||||
|
|
||||||
|
// ── shipping rates ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Live per-country rates from Sendcloud's shipping_methods API, cached to
|
||||||
|
// a state file and refreshed daily by a background thread. The compiled-in
|
||||||
|
// zone table (Catcrafts.Shared:Content) remains the fallback for any country the carrier table
|
||||||
|
// does not cover — and the whole feature when no credentials exist, so
|
||||||
|
// the shop never depends on Sendcloud being up.
|
||||||
|
|
||||||
|
struct ShippingConfig {
|
||||||
|
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
||||||
|
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
||||||
|
std::string methodName; // substring match on the method name
|
||||||
|
std::filesystem::path cachePath; // survives restarts
|
||||||
|
};
|
||||||
|
|
||||||
|
// Country -> price in cents, EUR. Empty when nothing loaded.
|
||||||
|
struct ShippingTable {
|
||||||
|
std::string method; // the matched Sendcloud method name
|
||||||
|
std::string fetchedAt; // ISO 8601, for the operator
|
||||||
|
std::vector<std::pair<std::string, std::int64_t>> perCountry;
|
||||||
|
|
||||||
|
std::int64_t Find(std::string_view cc) const {
|
||||||
|
for (const auto& [k, v] : perCountry) {
|
||||||
|
if (k == cc) return v;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
|
||||||
|
// the first method whose name contains `methodName` (case-sensitive).
|
||||||
|
// Exported for the self-test — the network fetch is thin around this.
|
||||||
|
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
||||||
|
|
||||||
|
// Install the config and start using it. Safe to skip entirely.
|
||||||
|
void ConfigureShipping(const ShippingConfig& config);
|
||||||
|
|
||||||
|
// One fetch attempt; failure leaves the previous table standing. The HTTP
|
||||||
|
// layer's background thread calls this on start and daily after.
|
||||||
|
void RefreshShippingTable();
|
||||||
|
|
||||||
|
// The rate the checkout charges for `country`: the live table's price if
|
||||||
|
// present, the product's zone fallback otherwise.
|
||||||
|
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||||
|
std::int64_t zoneEu, std::int64_t zoneWorld);
|
||||||
|
|
||||||
|
// A snapshot of the live table for embedding into the checkout preview —
|
||||||
|
// the page must show the same numbers the server will charge.
|
||||||
|
ShippingTable CurrentShippingTable();
|
||||||
|
|
||||||
|
// Install the rail used by Serve()'s checkout handler and reconciler.
|
||||||
|
// Call before Serve. Passing nullptr disables checkout. (Rates travel with
|
||||||
|
// the content — LoadContent reads rates.json.)
|
||||||
|
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase);
|
||||||
|
|
||||||
|
// Bind and serve until killed. Blocks. Starts the payment reconciler
|
||||||
|
// thread when a rail is configured.
|
||||||
|
//
|
||||||
|
// Plaintext HTTP/1.1 by design: Caddy terminates TLS and reverse-proxies to
|
||||||
|
// localhost. Do not expose this port directly.
|
||||||
|
int Serve(std::uint16_t port);
|
||||||
|
}
|
||||||
259
shared/interfaces/Catcrafts.Shared-Content.cppm
Normal file
259
shared/interfaces/Catcrafts.Shared-Content.cppm
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The site's authored content, as code.
|
||||||
|
//
|
||||||
|
// This replaced content/{products,projects,legal,demos}.json and their
|
||||||
|
// runtime loaders. The reasoning: this codebase's whole style is making
|
||||||
|
// invalid states fail at COMPILE time (SafeHtml, integer money), while the
|
||||||
|
// JSON loaders did the opposite — a typo'd field silently dropped a variant,
|
||||||
|
// and one stray trailing comma once blanked every legal page at runtime while
|
||||||
|
// the server kept answering 200. Content edits go through git push and a full
|
||||||
|
// CI build regardless (there is no content-only deploy), so the "edit without
|
||||||
|
// a toolchain" argument bought nothing here. Now a broken product is a
|
||||||
|
// compile error, the wasm bundle ships four fewer files, and the duplicated
|
||||||
|
// keep-in-step loaders in main.cpp and the server module are gone.
|
||||||
|
//
|
||||||
|
// posts.json and rates.json remain data on purpose: shell pipelines write
|
||||||
|
// them at build time (fediverse fetch, ECB rates), and shell writes JSON,
|
||||||
|
// not C++.
|
||||||
|
//
|
||||||
|
// PRICING RULE (the user's): retail = supplier price + markup, exactly.
|
||||||
|
// Supplier prices are what the retailer currently charges (incl VAT);
|
||||||
|
// change one number when the supplier moves and the margin stays put.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Content;
|
||||||
|
import std;
|
||||||
|
import :Model;
|
||||||
|
|
||||||
|
namespace Catcrafts::Content {
|
||||||
|
|
||||||
|
// The flat markup on every variant — "whatever it costs me + 50".
|
||||||
|
inline constexpr std::int64_t kMarkupMinor = 5000;
|
||||||
|
|
||||||
|
export const std::vector<Product>& Products() {
|
||||||
|
static const std::vector<Product> products = [] {
|
||||||
|
Product p;
|
||||||
|
p.slug = "fp6-pmos";
|
||||||
|
p.name = "Fairphone 6 with postmarketOS";
|
||||||
|
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
|
||||||
|
// Launch day is this one line: "coming-soon" -> "available". The page
|
||||||
|
// shows launch prices either way; only the order form is held back.
|
||||||
|
p.status = "coming-soon";
|
||||||
|
p.shipNlMinor = 1500; // zone FALLBACKS — the live
|
||||||
|
p.shipEuMinor = 2500; // Sendcloud table overrides
|
||||||
|
p.shipWorldMinor = 5500; // these per country
|
||||||
|
p.image = "/fp6-pmos.jpg";
|
||||||
|
p.summary = "A Fairphone 6, reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||||
|
p.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. EU consumers hold their statutory rights on top of all this; nothing here limits them. The full terms are on the terms page.";
|
||||||
|
// The one claim on this page that is about safety rather than
|
||||||
|
// features. It stays until emergency calling has been verified on a
|
||||||
|
// real network — removing it is a decision, not a cleanup.
|
||||||
|
p.safetyNote = "Emergency calling (112/911) is implemented in imsd, including carrier-broadcast emergency numbers, but not yet verified against a live network.";
|
||||||
|
p.variants = {
|
||||||
|
// supplier €513.30 incl VAT
|
||||||
|
{ "green", "Forest Green", 51330 + kMarkupMinor },
|
||||||
|
// supplier €519.30 incl VAT
|
||||||
|
{ "black", "Black", 51930 + kMarkupMinor },
|
||||||
|
// supplier €604.88 incl VAT
|
||||||
|
{ "white", "White", 60488 + kMarkupMinor },
|
||||||
|
};
|
||||||
|
// The hardware, as Fairphone specifies it — this is a stock Fairphone
|
||||||
|
// 6, so its spec sheet is this product's spec sheet.
|
||||||
|
p.specs = {
|
||||||
|
{ "Display", "6.31″ OLED, 2484 × 1116 (FHD+), up to 120 Hz, Gorilla Glass 7i" },
|
||||||
|
{ "Processor", "Qualcomm Snapdragon 7s Gen 3, Adreno 810 GPU" },
|
||||||
|
{ "Memory", "8 GB RAM" },
|
||||||
|
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
||||||
|
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
||||||
|
{ "Front camera", "32 MP" },
|
||||||
|
{ "Battery", "4415 mAh, user-replaceable, 30 W fast charging" },
|
||||||
|
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
||||||
|
{ "SIM", "Dual: nano-SIM + eSIM" },
|
||||||
|
{ "USB", "USB-C 2.0" },
|
||||||
|
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
||||||
|
{ "Durability", "IP55, 12 user-replaceable modules" },
|
||||||
|
{ "Dimensions", "156.5 × 73.3 × 9.6 mm, 193 g" },
|
||||||
|
};
|
||||||
|
// Same rule the loader used to apply: the from-price is the
|
||||||
|
// cheapest variant, derived so the two can never disagree.
|
||||||
|
if (const Variant* cheapest = p.CheapestVariant()) {
|
||||||
|
p.priceInclMinor = cheapest->priceInclMinor;
|
||||||
|
}
|
||||||
|
return std::vector<Product>{ std::move(p) };
|
||||||
|
}();
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const std::vector<Project>& Projects() {
|
||||||
|
static const std::vector<Project> projects = {
|
||||||
|
{
|
||||||
|
"imsd",
|
||||||
|
"IMS/VoLTE for the Fairphone 6's Qualcomm modem: calls on a mainline-Linux phone.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/imsd",
|
||||||
|
"C++23",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Crafter.Graphics",
|
||||||
|
"A C++23 rendering engine: Vulkan with hardware ray tracing natively, WebGPU in the browser.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Graphics",
|
||||||
|
"C++23 modules",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Crafter.Build",
|
||||||
|
"A build system with no DSL: the build description is a C++ file, compiled and run.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Build",
|
||||||
|
"C++23 modules",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Crafter.Network",
|
||||||
|
"A QUIC and HTTP/3 stack; the same client code runs natively and in the browser.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Network",
|
||||||
|
"C++23 modules",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catcrafts.net",
|
||||||
|
"This website, C++23 compiled to WebAssembly, server-rendered from the same renderers.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/catcrafts.net",
|
||||||
|
"C++23 modules",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Crafter.Asset",
|
||||||
|
"Texture and mesh pipeline to GPU-friendly formats, decoded on the GPU where possible.",
|
||||||
|
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset",
|
||||||
|
"C++23 modules",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const std::vector<Demo>& Demos() {
|
||||||
|
static const std::vector<Demo> demos = {
|
||||||
|
{
|
||||||
|
.slug = "raytracer",
|
||||||
|
.name = "Real-time ray tracer",
|
||||||
|
.blurb = "Hardware-accelerated ray tracing through WebGPU, driven by the same C++23 WebAssembly module that renders this page. Four coloured lights, one shadow ray each, Reinhard tonemapping.",
|
||||||
|
.tech = "WebGPU compute + WGSL",
|
||||||
|
.needs = "WebGPU: Chrome 121+, Firefox 141+, Safari 26+",
|
||||||
|
.mountId = "webgpu-demo",
|
||||||
|
.aspect = "16 / 9",
|
||||||
|
.needsWasm = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return demos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const std::vector<LegalPage>& LegalPages() {
|
||||||
|
static const std::vector<LegalPage> pages = {
|
||||||
|
{
|
||||||
|
.slug = "privacy",
|
||||||
|
.title = "Privacy",
|
||||||
|
.updated = "2026-08-04",
|
||||||
|
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
|
||||||
|
.sections = {
|
||||||
|
{ "Who is responsible",
|
||||||
|
{
|
||||||
|
"Catcrafts, Netherlands. Contact details are on the imprint page. Catcrafts is the controller for everything described here; no other party is involved.",
|
||||||
|
} },
|
||||||
|
{ "Orders",
|
||||||
|
{
|
||||||
|
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
|
||||||
|
"Payment happens at Mollie, a Dutch licensed payment provider, on their pages. Catcrafts never sees card numbers or bank credentials. It learns only which order was paid, for how much, and by which method. What Mollie processes about you is between you and Mollie under their own privacy policy.",
|
||||||
|
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
|
||||||
|
} },
|
||||||
|
{ "How long it is kept",
|
||||||
|
{
|
||||||
|
"Order records that belong to an invoice are kept for seven years. That is the Dutch fiscal retention obligation, and it is the one case where a deletion request cannot be honoured early. Everything in an order that the tax records do not need is deleted on request.",
|
||||||
|
"An order that is never paid lapses; its record is kept briefly for abuse spotting and then has no reason to exist.",
|
||||||
|
"The order file is stored on a machine Catcrafts runs, readable only by the service account, and encrypted before any backup leaves that machine.",
|
||||||
|
} },
|
||||||
|
{ "What this site does not do",
|
||||||
|
{
|
||||||
|
"No analytics. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
|
||||||
|
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||||
|
} },
|
||||||
|
{ "Server logs",
|
||||||
|
{
|
||||||
|
"The web server keeps ordinary request logs. Those exist to debug faults and spot abuse, and are not connected to order records or used to build any kind of profile.",
|
||||||
|
} },
|
||||||
|
{ "Your rights",
|
||||||
|
{
|
||||||
|
"Under the GDPR you can ask for a copy of what Catcrafts holds about you, have it corrected or deleted, restrict how it is used, object to its use, or ask for it in a portable form. Please contact privacy@catcrafts.net for this purpose.",
|
||||||
|
} },
|
||||||
|
{ "Changes",
|
||||||
|
{
|
||||||
|
"This page has a date at the top. Anything that changes what is collected or why will be a new date and a note in the posts feed, not a silent edit.",
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.slug = "imprint",
|
||||||
|
.title = "Imprint & contact",
|
||||||
|
.updated = "2026-08-04",
|
||||||
|
.lede = "Who is behind this site and how to reach them.",
|
||||||
|
.sections = {
|
||||||
|
{ "Contact",
|
||||||
|
{
|
||||||
|
"Email info@catcrafts.net. One inbox answers everything: support, orders, privacy requests and anything else. The topical addresses named elsewhere on this site (privacy@catcrafts.net, security@catcrafts.net) reach the same person.",
|
||||||
|
"For anything about the code itself, an issue on Forgejo is usually better than email: it stays public and searchable for the next person with the same question.",
|
||||||
|
} },
|
||||||
|
{ "Business details",
|
||||||
|
{
|
||||||
|
"Catcrafts, KVK 78437059, VAT NL003329281B38.",
|
||||||
|
} },
|
||||||
|
{ "Security reports",
|
||||||
|
{
|
||||||
|
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.slug = "terms",
|
||||||
|
.title = "Terms",
|
||||||
|
.updated = "2026-08-04",
|
||||||
|
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
|
||||||
|
.sections = {
|
||||||
|
{ "Ordering and payment",
|
||||||
|
{
|
||||||
|
"Submitting the order form creates an order and a Mollie payment link. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored.",
|
||||||
|
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
|
||||||
|
"Payment is handled by Mollie, a Dutch licensed payment institution. Catcrafts never sees your card number or bank credentials.",
|
||||||
|
"For support related to orders please contact orders@catcrafts.net"
|
||||||
|
} },
|
||||||
|
{ "Fulfilment",
|
||||||
|
{
|
||||||
|
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
|
||||||
|
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
|
||||||
|
} },
|
||||||
|
{ "Warranty",
|
||||||
|
{
|
||||||
|
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",
|
||||||
|
"Behind that counter the split is simple. Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere. Hardware faults are handled through the manufacturer's or supplier's warranty: Catcrafts keeps the purchase paperwork those claims depend on and runs the process end to end. What that means for a specific product is described on its own page.",
|
||||||
|
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
|
||||||
|
"If you are an EU consumer you additionally hold the statutory conformity guarantee: under Dutch law it lasts as long as a product of this kind may reasonably be expected to last, a defect appearing in the first year is presumed to have existed at delivery, and remedies under it are free. Nothing in this section limits those rights.",
|
||||||
|
"For warranty please contact warranty@catcrafts.net"
|
||||||
|
} },
|
||||||
|
{ "Returns",
|
||||||
|
{
|
||||||
|
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded within 14 days of your notice, though the refund can wait until the product is back or you show it has been shipped. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop; value lost through use beyond that can be deducted from the refund.",
|
||||||
|
"Outside the EU, sales are final except for defects: the warranty above applies in full, but there is no change-of-mind window. Import duties and fees paid to your own authorities are between you and them and are never refunded by Catcrafts in any case.",
|
||||||
|
"A product that arrives broken is a warranty case, not a return: the warranty section applies, and the shipping is on Catcrafts.",
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Content
|
||||||
295
shared/interfaces/Catcrafts.Shared-Form.cppm
Normal file
295
shared/interfaces/Catcrafts.Shared-Form.cppm
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// application/x-www-form-urlencoded parsing and field validation.
|
||||||
|
//
|
||||||
|
// Lives in Catcrafts.Shared rather than the server because it is pure string
|
||||||
|
// work with no I/O, which means it can be exercised on the host with real
|
||||||
|
// assertions instead of only against a live socket. Checkout will reuse all of
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// Two decisions worth stating up front:
|
||||||
|
//
|
||||||
|
// * Validation returns a list of per-field errors rather than throwing or
|
||||||
|
// returning the first failure. A form that reports one problem at a time
|
||||||
|
// makes the user resubmit repeatedly to discover the rest.
|
||||||
|
//
|
||||||
|
// * Every limit is explicit and every field is length-capped. Input arrives
|
||||||
|
// from anyone on the internet, and "how long can this be" is not a
|
||||||
|
// question to leave to whatever the caller happens to allocate.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Form;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Catcrafts::Form {
|
||||||
|
|
||||||
|
// Hard cap on a whole request body. Well above any legitimate submission here;
|
||||||
|
// the point is that an unbounded body cannot make the server allocate without
|
||||||
|
// limit before parsing even starts.
|
||||||
|
export inline constexpr std::size_t kMaxBodyBytes = 16 * 1024;
|
||||||
|
// Per-field cap, applied after decoding.
|
||||||
|
export inline constexpr std::size_t kMaxFieldBytes = 1024;
|
||||||
|
|
||||||
|
export class Fields {
|
||||||
|
public:
|
||||||
|
// First value for `name`, or empty. Duplicates keep the first: a repeated
|
||||||
|
// field in a submission is either a bug or someone probing, and taking the
|
||||||
|
// first is the predictable choice.
|
||||||
|
std::string_view Get(std::string_view name) const {
|
||||||
|
for (const auto& [k, v] : pairs_) {
|
||||||
|
if (k == name) return v;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
bool Has(std::string_view name) const {
|
||||||
|
for (const auto& [k, v] : pairs_) {
|
||||||
|
if (k == name) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::size_t Size() const noexcept { return pairs_.size(); }
|
||||||
|
|
||||||
|
void Add(std::string key, std::string value) {
|
||||||
|
pairs_.emplace_back(std::move(key), std::move(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::pair<std::string, std::string>> pairs_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Percent-decode one component, treating '+' as space per the
|
||||||
|
// urlencoded serialisation. A malformed escape is passed through literally
|
||||||
|
// rather than dropped, so a stray '%' survives a round trip instead of
|
||||||
|
// silently mangling the value.
|
||||||
|
export std::string PercentDecode(std::string_view in) {
|
||||||
|
auto hex = [](char c) -> int {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
std::string out;
|
||||||
|
out.reserve(in.size());
|
||||||
|
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||||
|
const char c = in[i];
|
||||||
|
if (c == '+') {
|
||||||
|
out.push_back(' ');
|
||||||
|
} else if (c == '%' && i + 2 < in.size()) {
|
||||||
|
const int hi = hex(in[i + 1]);
|
||||||
|
const int lo = hex(in[i + 2]);
|
||||||
|
if (hi >= 0 && lo >= 0) {
|
||||||
|
out.push_back(static_cast<char>(hi * 16 + lo));
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a urlencoded body. Oversized bodies yield nothing rather than a partial
|
||||||
|
// parse — a truncated form is not something to act on.
|
||||||
|
export std::optional<Fields> ParseUrlEncoded(std::string_view body) {
|
||||||
|
if (body.size() > kMaxBodyBytes) return std::nullopt;
|
||||||
|
Fields out;
|
||||||
|
while (!body.empty()) {
|
||||||
|
const std::size_t amp = body.find('&');
|
||||||
|
std::string_view pair = body.substr(0, amp);
|
||||||
|
body = (amp == std::string_view::npos) ? std::string_view{} : body.substr(amp + 1);
|
||||||
|
if (pair.empty()) continue; // tolerate "a=1&&b=2"
|
||||||
|
const std::size_t eq = pair.find('=');
|
||||||
|
std::string key = PercentDecode(eq == std::string_view::npos ? pair : pair.substr(0, eq));
|
||||||
|
std::string val = eq == std::string_view::npos ? std::string{}
|
||||||
|
: PercentDecode(pair.substr(eq + 1));
|
||||||
|
if (key.empty() || key.size() > kMaxFieldBytes || val.size() > kMaxFieldBytes) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
out.Add(std::move(key), std::move(val));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export struct FieldError {
|
||||||
|
std::string field;
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trim ASCII whitespace. Deliberately not locale-aware: these are machine
|
||||||
|
// fields (an address, a country code), not prose.
|
||||||
|
export std::string_view Trim(std::string_view s) {
|
||||||
|
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r'
|
||||||
|
|| s.front() == '\n')) s.remove_prefix(1);
|
||||||
|
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r'
|
||||||
|
|| s.back() == '\n')) s.remove_suffix(1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately permissive email check.
|
||||||
|
//
|
||||||
|
// Not a regex from a blog post and not an RFC 5322 parser. Fully validating an
|
||||||
|
// address is impossible without sending to it, and every strict validator in
|
||||||
|
// the wild rejects addresses that genuinely work (new TLDs, tagged local parts,
|
||||||
|
// unicode domains). So this rejects only what is definitely not an address —
|
||||||
|
// no '@', nothing before or after it, a dotless domain, whitespace, control
|
||||||
|
// characters — and lets delivery be the real test.
|
||||||
|
export bool LooksLikeEmail(std::string_view s) {
|
||||||
|
if (s.size() < 3 || s.size() > 254) return false;
|
||||||
|
const std::size_t at = s.find('@');
|
||||||
|
if (at == std::string_view::npos || at == 0 || at + 1 >= s.size()) return false;
|
||||||
|
// Exactly one '@': a second one is unambiguously malformed.
|
||||||
|
if (s.find('@', at + 1) != std::string_view::npos) return false;
|
||||||
|
const std::string_view domain = s.substr(at + 1);
|
||||||
|
const std::size_t dot = domain.find('.');
|
||||||
|
if (dot == std::string_view::npos || dot == 0 || dot + 1 >= domain.size()) return false;
|
||||||
|
for (const char c : s) {
|
||||||
|
if (static_cast<unsigned char>(c) <= 0x20 || c == 0x7F) return false;
|
||||||
|
if (c == ',' || c == ';' || c == '<' || c == '>' || c == '"' || c == '\\') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there
|
||||||
|
// is a policy question answered elsewhere, not a validation one.
|
||||||
|
export bool LooksLikeCountryCode(std::string_view s) {
|
||||||
|
if (s.size() != 2) return false;
|
||||||
|
for (const char c : s) {
|
||||||
|
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export std::string Upper(std::string_view s) {
|
||||||
|
std::string out(s);
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── checkout ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// What the buy form collects: enough to ship a parcel and send an invoice, and
|
||||||
|
// nothing more. No account, no phone number, no marketing checkbox. The amount
|
||||||
|
// is deliberately NOT a field — money never comes from the client; the server
|
||||||
|
// computes it from the product record and the country.
|
||||||
|
export struct Checkout {
|
||||||
|
std::string email;
|
||||||
|
std::string name; // recipient, as it should appear on the label
|
||||||
|
std::string street; // street + number, one line
|
||||||
|
std::string postal;
|
||||||
|
std::string city;
|
||||||
|
std::string country; // ISO-3166-1 alpha-2, uppercased
|
||||||
|
std::string color; // variant slug; whether it EXISTS is the handler's
|
||||||
|
// check against the catalogue, not a shape check
|
||||||
|
std::int64_t quantity = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A technical sanity bound, not a business cap — bulk orders are welcome.
|
||||||
|
// It exists because the integer math (here and mirrored in the preview
|
||||||
|
// script) and a bunq payment link both need SOME ceiling, and an order of a
|
||||||
|
// hundred phones deserves an email conversation more than a form submit.
|
||||||
|
export inline constexpr std::int64_t kMaxQuantity = 99;
|
||||||
|
|
||||||
|
export struct CheckoutResult {
|
||||||
|
Checkout value;
|
||||||
|
std::vector<FieldError> errors;
|
||||||
|
bool Ok() const { return errors.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate a submitted checkout.
|
||||||
|
//
|
||||||
|
// The honeypot: the form renders a field that a human never sees and never
|
||||||
|
// fills. Anything in it means an automated submission, which is reported as a
|
||||||
|
// generic failure rather than "you tripped the honeypot" — naming the trap
|
||||||
|
// teaches the next bot how to avoid it.
|
||||||
|
export CheckoutResult ValidateCheckout(const Fields& f) {
|
||||||
|
CheckoutResult r;
|
||||||
|
|
||||||
|
if (!Trim(f.Get("website")).empty()) {
|
||||||
|
r.errors.push_back({ "", "Submission rejected." });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every field is echoed back into `value` even when it fails validation, so
|
||||||
|
// the caller can re-render the form with what the visitor typed. Discarding
|
||||||
|
// a rejected field means making them retype the one thing they got wrong,
|
||||||
|
// which is how a submission gets abandoned. `value` is only ever *stored*
|
||||||
|
// when Ok() is true, so an invalid value cannot leak into the record.
|
||||||
|
const std::string_view email = Trim(f.Get("email"));
|
||||||
|
r.value.email = std::string(email);
|
||||||
|
if (email.empty()) {
|
||||||
|
r.errors.push_back({ "email", "An email address is required — order updates go there." });
|
||||||
|
} else if (!LooksLikeEmail(email)) {
|
||||||
|
r.errors.push_back({ "email", "That doesn't look like an email address." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A required free-text field: reject empty and oversize, accept everything
|
||||||
|
// else. Names, streets and cities worldwide defeat any stricter shape check
|
||||||
|
// — validating them harder only rejects real addresses.
|
||||||
|
auto requiredText = [&](std::string_view fieldName, std::string& into,
|
||||||
|
std::size_t maxLen, std::string_view emptyMsg) {
|
||||||
|
const std::string_view v = Trim(f.Get(fieldName));
|
||||||
|
into = std::string(v);
|
||||||
|
if (v.empty()) {
|
||||||
|
r.errors.push_back({ std::string(fieldName), std::string(emptyMsg) });
|
||||||
|
} else if (v.size() > maxLen) {
|
||||||
|
r.errors.push_back({ std::string(fieldName), "Too long." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requiredText("name", r.value.name, 120, "A recipient name is required — it goes on the label.");
|
||||||
|
requiredText("street", r.value.street, 200, "A street address is required.");
|
||||||
|
requiredText("postal", r.value.postal, 20, "A postal code is required.");
|
||||||
|
requiredText("city", r.value.city, 120, "A city is required.");
|
||||||
|
|
||||||
|
const std::string_view country = Trim(f.Get("country"));
|
||||||
|
// Normalise on the way in so a valid code is stored uppercase; an invalid
|
||||||
|
// one is echoed as typed so the visitor recognises their own input.
|
||||||
|
r.value.country = LooksLikeCountryCode(country) ? Upper(country) : std::string(country);
|
||||||
|
if (country.empty()) {
|
||||||
|
r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
|
||||||
|
} else if (!LooksLikeCountryCode(country)) {
|
||||||
|
r.errors.push_back({ "country", "Country must be a two-letter code." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour: shape only (slug-ish, bounded). Whether it names a variant that
|
||||||
|
// exists — and what it costs — is the catalogue's answer, in the handler.
|
||||||
|
const std::string_view color = Trim(f.Get("color"));
|
||||||
|
r.value.color = std::string(color);
|
||||||
|
if (color.size() > 32) {
|
||||||
|
r.errors.push_back({ "color", "That is not one of the colours." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
||||||
|
// no-JS form default); anything unparseable or out of range is rejected
|
||||||
|
// rather than clamped — silently changing how many phones someone buys is
|
||||||
|
// worse than asking again.
|
||||||
|
const std::string_view qty = Trim(f.Get("quantity"));
|
||||||
|
if (qty.empty()) {
|
||||||
|
r.value.quantity = 1;
|
||||||
|
} else {
|
||||||
|
std::int64_t parsed = 0;
|
||||||
|
auto [ptr, ec] = std::from_chars(qty.data(), qty.data() + qty.size(), parsed);
|
||||||
|
if (ec != std::errc{} || ptr != qty.data() + qty.size()
|
||||||
|
|| parsed < 1 || parsed > kMaxQuantity) {
|
||||||
|
r.value.quantity = 1;
|
||||||
|
r.errors.push_back({ "quantity",
|
||||||
|
std::format("Quantity must be between 1 and {}.", kMaxQuantity) });
|
||||||
|
} else {
|
||||||
|
r.value.quantity = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Form
|
||||||
207
shared/interfaces/Catcrafts.Shared-Html.cppm
Normal file
207
shared/interfaces/Catcrafts.Shared-Html.cppm
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// HTML construction with escaping enforced by the type system.
|
||||||
|
//
|
||||||
|
// The problem this solves: Crafter.Graphics has no setAttribute-shaped API for
|
||||||
|
// most of what a page needs, so markup is built as strings and handed to
|
||||||
|
// SetInnerHTML. Any product name, post title or user-supplied field
|
||||||
|
// interpolated into one of those strings is an XSS sink, and "remember to
|
||||||
|
// escape" is not a strategy that survives a codebase.
|
||||||
|
//
|
||||||
|
// So: SafeHtml is an opaque wrapper whose constructors from string types are
|
||||||
|
// DELETED. The only ways to obtain one are Escape() (escapes), Num()/Money()
|
||||||
|
// (can't contain markup), Url() (scheme-allowlisted), and Raw() (the single
|
||||||
|
// audited escape hatch). Format() then accepts only SafeHtml arguments, so
|
||||||
|
//
|
||||||
|
// Html::Format("<h2>{}</h2>", post.title) // std::string -> COMPILE ERROR
|
||||||
|
// Html::Format("<h2>{}</h2>", Escape(title)) // ok
|
||||||
|
//
|
||||||
|
// The failure mode for forgetting to escape is a build failure, not a stored
|
||||||
|
// cross-site-scripting bug.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Html;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Catcrafts::Html {
|
||||||
|
|
||||||
|
export class SafeHtml {
|
||||||
|
public:
|
||||||
|
SafeHtml() = default;
|
||||||
|
|
||||||
|
// Deleted so no string type can become SafeHtml implicitly. Without
|
||||||
|
// these, `SafeHtml h = userInput;` would silently compile and the whole
|
||||||
|
// guarantee would be decorative.
|
||||||
|
SafeHtml(const char*) = delete;
|
||||||
|
SafeHtml(std::string) = delete;
|
||||||
|
SafeHtml(std::string_view) = delete;
|
||||||
|
|
||||||
|
// Str() returns a reference (not a copy) because Format() feeds these
|
||||||
|
// to std::make_format_args, which in C++23 binds Args&... and therefore
|
||||||
|
// needs lvalues.
|
||||||
|
const std::string& Str() const noexcept { return v_; }
|
||||||
|
std::string_view View() const noexcept { return v_; }
|
||||||
|
bool Empty() const noexcept { return v_.empty(); }
|
||||||
|
std::size_t Size() const noexcept { return v_.size(); }
|
||||||
|
|
||||||
|
SafeHtml& operator+=(const SafeHtml& r) { v_ += r.v_; return *this; }
|
||||||
|
friend SafeHtml operator+(SafeHtml l, const SafeHtml& r) { l += r; return l; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Private tagged ctor: the ONLY path from a raw string into the type.
|
||||||
|
// Every friend below is a function that has established the string is
|
||||||
|
// safe to emit, either by escaping it or by generating it itself.
|
||||||
|
struct TrustedTag {};
|
||||||
|
SafeHtml(TrustedTag, std::string v) : v_(std::move(v)) {}
|
||||||
|
std::string v_;
|
||||||
|
|
||||||
|
friend SafeHtml Escape(std::string_view);
|
||||||
|
friend SafeHtml Raw(std::string_view);
|
||||||
|
friend SafeHtml Num(std::int64_t);
|
||||||
|
friend SafeHtml Attr(std::string_view, std::string_view);
|
||||||
|
friend SafeHtml Url(std::string_view, std::string_view);
|
||||||
|
friend SafeHtml Join(std::span<const SafeHtml>, const SafeHtml&);
|
||||||
|
template <class... Ts> friend SafeHtml FormatImpl(std::string_view, const Ts&...);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escape for both text and attribute contexts in a single pass.
|
||||||
|
//
|
||||||
|
// Quotes are escaped even though they are harmless in text content, so that
|
||||||
|
// ONE function is correct in every context. The alternative — a text escaper
|
||||||
|
// and an attribute escaper — means every call site is a chance to pick wrong,
|
||||||
|
// which is the bug this module exists to prevent.
|
||||||
|
export SafeHtml Escape(std::string_view text) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(text.size() + text.size() / 8);
|
||||||
|
for (const char c : text) {
|
||||||
|
switch (c) {
|
||||||
|
case '&': out += "&"; break;
|
||||||
|
case '<': out += "<"; break;
|
||||||
|
case '>': out += ">"; break;
|
||||||
|
case '"': out += """; break;
|
||||||
|
case '\'': out += "'"; break;
|
||||||
|
default: out += c; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integers can't carry markup, so they pass through unescaped.
|
||||||
|
export SafeHtml Num(std::int64_t n) {
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{}, std::to_string(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single escape hatch. Every call is a claim that the argument is markup
|
||||||
|
// this codebase generated. Kept greppable and lint-gated to a small allowlist
|
||||||
|
// of files — if it starts appearing in view code, the discipline has failed.
|
||||||
|
export SafeHtml Raw(std::string_view trustedMarkup) {
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{}, std::string(trustedMarkup));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `name="escaped-value"`, including the leading space, or empty when the
|
||||||
|
// value is empty — so optional attributes compose without leaving stray
|
||||||
|
// whitespace or a bare `alt=""` where none was wanted.
|
||||||
|
//
|
||||||
|
// The name is validated rather than escaped: an attribute name is never
|
||||||
|
// user data in this codebase, and silently emitting a mangled one would
|
||||||
|
// hide a bug. An invalid name yields nothing.
|
||||||
|
export SafeHtml Attr(std::string_view name, std::string_view value) {
|
||||||
|
for (const char c : name) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':';
|
||||||
|
if (!ok) return SafeHtml{};
|
||||||
|
}
|
||||||
|
if (name.empty() || value.empty()) return SafeHtml{};
|
||||||
|
std::string out = " ";
|
||||||
|
out += name;
|
||||||
|
out += "=\"";
|
||||||
|
out += Escape(value).Str();
|
||||||
|
out += '"';
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
// href/src emission with a scheme allowlist.
|
||||||
|
//
|
||||||
|
// Escaping alone does not make a URL safe: `javascript:alert(1)` contains no
|
||||||
|
// character that needs escaping, so an escaped-but-unvalidated href is still
|
||||||
|
// script execution. Anything not clearly http/https/mailto or site-relative
|
||||||
|
// is replaced with "#" rather than dropped, so a bad link is visibly inert
|
||||||
|
// instead of silently vanishing from the markup.
|
||||||
|
export SafeHtml Url(std::string_view attrName, std::string_view href) {
|
||||||
|
auto startsWithNoCase = [](std::string_view s, std::string_view prefix) {
|
||||||
|
if (s.size() < prefix.size()) return false;
|
||||||
|
for (std::size_t i = 0; i < prefix.size(); ++i) {
|
||||||
|
char a = s[i];
|
||||||
|
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
|
||||||
|
if (a != prefix[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leading control characters and whitespace are stripped by browsers
|
||||||
|
// before scheme detection, so "java\tscript:" would slip past a naive
|
||||||
|
// prefix test. Strip them here first and validate what remains.
|
||||||
|
std::string cleaned;
|
||||||
|
cleaned.reserve(href.size());
|
||||||
|
for (const char c : href) {
|
||||||
|
if (static_cast<unsigned char>(c) > 0x20) cleaned += c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool safe =
|
||||||
|
startsWithNoCase(cleaned, "https://")
|
||||||
|
|| startsWithNoCase(cleaned, "http://")
|
||||||
|
|| startsWithNoCase(cleaned, "mailto:")
|
||||||
|
// Site-relative, but NOT protocol-relative ("//evil.example" would
|
||||||
|
// leave the origin while looking like a path).
|
||||||
|
|| (cleaned.size() >= 1 && cleaned[0] == '/'
|
||||||
|
&& !(cleaned.size() >= 2 && cleaned[1] == '/'))
|
||||||
|
|| (!cleaned.empty() && cleaned[0] == '#');
|
||||||
|
|
||||||
|
return Attr(attrName, safe ? std::string_view(cleaned) : std::string_view("#"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export SafeHtml Join(std::span<const SafeHtml> parts, const SafeHtml& sep = {}) {
|
||||||
|
std::string out;
|
||||||
|
std::size_t total = 0;
|
||||||
|
for (const SafeHtml& p : parts) total += p.Size() + sep.Size();
|
||||||
|
out.reserve(total);
|
||||||
|
bool first = true;
|
||||||
|
for (const SafeHtml& p : parts) {
|
||||||
|
if (!first) out += sep.Str();
|
||||||
|
out += p.Str();
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only SafeHtml may be interpolated.
|
||||||
|
export template <class T>
|
||||||
|
concept Safe = std::same_as<std::remove_cvref_t<T>, SafeHtml>;
|
||||||
|
|
||||||
|
template <class... Ts>
|
||||||
|
SafeHtml FormatImpl(std::string_view fmt, const Ts&... args) {
|
||||||
|
return SafeHtml(SafeHtml::TrustedTag{},
|
||||||
|
std::vformat(fmt, std::make_format_args(args.Str()...)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps each SafeHtml parameter to std::string for the format-string check,
|
||||||
|
// so std::format_string validates placeholder count and syntax at compile
|
||||||
|
// time against the real argument list.
|
||||||
|
template <class T> using AsString = std::string;
|
||||||
|
|
||||||
|
// The gate. Two properties, both enforced by the signature:
|
||||||
|
// * std::format_string means the template must be a compile-time constant,
|
||||||
|
// so a runtime-assembled template can't be smuggled in;
|
||||||
|
// * `Safe... Ts` means every argument is already SafeHtml, so a bare
|
||||||
|
// std::string, const char*, int or string_view fails to compile.
|
||||||
|
export template <Safe... Ts>
|
||||||
|
SafeHtml Format(std::format_string<AsString<Ts>...> fmt, const Ts&... args) {
|
||||||
|
return FormatImpl(fmt.get(), args...);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Html
|
||||||
334
shared/interfaces/Catcrafts.Shared-Json.cppm
Normal file
334
shared/interfaces/Catcrafts.Shared-Json.cppm
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A small, strict JSON reader.
|
||||||
|
//
|
||||||
|
// Why not vendor nlohmann/json: Catcrafts.Shared may import `std` and nothing
|
||||||
|
// else (see Catcrafts.Shared.cppm for why that boundary is absolute), and
|
||||||
|
// json.hpp wants a global module fragment plus exceptions — the wasm build is
|
||||||
|
// -fno-exceptions. This reads the one document shape the site actually needs
|
||||||
|
// (content/posts.json, generated by CI from the Lemmy API) and refuses
|
||||||
|
// everything else loudly.
|
||||||
|
//
|
||||||
|
// Deliberately NOT a general-purpose parser. No streaming, no comments, no
|
||||||
|
// trailing commas, no big-number handling beyond int64. Errors are values,
|
||||||
|
// never exceptions, and a malformed document yields an error rather than a
|
||||||
|
// partial parse — a half-read post list rendering as a broken page is worse
|
||||||
|
// than an empty one.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Json;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Catcrafts::Json {
|
||||||
|
|
||||||
|
export enum class Type { Null, Bool, Number, String, Array, Object };
|
||||||
|
|
||||||
|
export class Value {
|
||||||
|
public:
|
||||||
|
Type type = Type::Null;
|
||||||
|
bool boolean = false;
|
||||||
|
double number = 0;
|
||||||
|
std::string string;
|
||||||
|
std::vector<Value> array;
|
||||||
|
// A vector rather than a map: object key order is preserved (useful when
|
||||||
|
// re-emitting) and these documents have a handful of keys, so linear
|
||||||
|
// lookup beats hashing.
|
||||||
|
std::vector<std::pair<std::string, Value>> object;
|
||||||
|
|
||||||
|
bool IsNull() const { return type == Type::Null; }
|
||||||
|
bool IsArray() const { return type == Type::Array; }
|
||||||
|
bool IsObject() const { return type == Type::Object; }
|
||||||
|
|
||||||
|
// Object lookup. Returns nullptr when absent, so callers distinguish
|
||||||
|
// "missing" from "present but null" without a second query.
|
||||||
|
const Value* Find(std::string_view key) const {
|
||||||
|
if (type != Type::Object) return nullptr;
|
||||||
|
for (const auto& [k, v] : object) {
|
||||||
|
if (k == key) return &v;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed accessors with a fallback. CI generates the input, so a missing
|
||||||
|
// or wrong-typed field is a bug in the generator rather than something a
|
||||||
|
// page render should abort over — default and carry on, and let the
|
||||||
|
// generator's own validation catch it.
|
||||||
|
std::string_view Str(std::string_view key, std::string_view fallback = {}) const {
|
||||||
|
const Value* v = Find(key);
|
||||||
|
return (v && v->type == Type::String) ? std::string_view(v->string) : fallback;
|
||||||
|
}
|
||||||
|
std::int64_t Int(std::string_view key, std::int64_t fallback = 0) const {
|
||||||
|
const Value* v = Find(key);
|
||||||
|
return (v && v->type == Type::Number) ? static_cast<std::int64_t>(v->number) : fallback;
|
||||||
|
}
|
||||||
|
bool Bool(std::string_view key, bool fallback = false) const {
|
||||||
|
const Value* v = Find(key);
|
||||||
|
return (v && v->type == Type::Bool) ? v->boolean : fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct ParseError {
|
||||||
|
std::string message;
|
||||||
|
std::size_t offset = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export using ParseResult = std::expected<Value, ParseError>;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
std::string_view s;
|
||||||
|
std::size_t i = 0;
|
||||||
|
|
||||||
|
std::unexpected<ParseError> Fail(std::string msg) {
|
||||||
|
return std::unexpected(ParseError{ std::move(msg), i });
|
||||||
|
}
|
||||||
|
|
||||||
|
void SkipWhitespace() {
|
||||||
|
while (i < s.size()) {
|
||||||
|
const char c = s[i];
|
||||||
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Literal(std::string_view lit) {
|
||||||
|
if (s.size() - i < lit.size()) return false;
|
||||||
|
if (s.compare(i, lit.size(), lit) != 0) return false;
|
||||||
|
i += lit.size();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appends the UTF-8 encoding of a code point. JSON escapes are UTF-16,
|
||||||
|
// so astral characters arrive as a surrogate pair and must be combined
|
||||||
|
// before encoding — emitting each half separately produces invalid UTF-8
|
||||||
|
// that will render as replacement characters (emoji in post titles are
|
||||||
|
// exactly this case).
|
||||||
|
static void AppendUtf8(std::string& out, char32_t cp) {
|
||||||
|
if (cp <= 0x7F) {
|
||||||
|
out += static_cast<char>(cp);
|
||||||
|
} else if (cp <= 0x7FF) {
|
||||||
|
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||||
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||||
|
} else if (cp <= 0xFFFF) {
|
||||||
|
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||||
|
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||||
|
} else {
|
||||||
|
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||||
|
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<char32_t> Hex4() {
|
||||||
|
if (s.size() - i < 4) return std::nullopt;
|
||||||
|
char32_t v = 0;
|
||||||
|
for (int k = 0; k < 4; ++k) {
|
||||||
|
const char c = s[i + k];
|
||||||
|
int d;
|
||||||
|
if (c >= '0' && c <= '9') d = c - '0';
|
||||||
|
else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
|
||||||
|
else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
|
||||||
|
else return std::nullopt;
|
||||||
|
v = v * 16 + static_cast<char32_t>(d);
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<std::string, ParseError> ParseString() {
|
||||||
|
if (i >= s.size() || s[i] != '"') return Fail("expected string");
|
||||||
|
++i;
|
||||||
|
std::string out;
|
||||||
|
while (true) {
|
||||||
|
if (i >= s.size()) return Fail("unterminated string");
|
||||||
|
const char c = s[i];
|
||||||
|
if (c == '"') { ++i; return out; }
|
||||||
|
if (c == '\\') {
|
||||||
|
++i;
|
||||||
|
if (i >= s.size()) return Fail("unterminated escape");
|
||||||
|
const char e = s[i++];
|
||||||
|
switch (e) {
|
||||||
|
case '"': out += '"'; break;
|
||||||
|
case '\\': out += '\\'; break;
|
||||||
|
case '/': out += '/'; break;
|
||||||
|
case 'b': out += '\b'; break;
|
||||||
|
case 'f': out += '\f'; break;
|
||||||
|
case 'n': out += '\n'; break;
|
||||||
|
case 'r': out += '\r'; break;
|
||||||
|
case 't': out += '\t'; break;
|
||||||
|
case 'u': {
|
||||||
|
auto hi = Hex4();
|
||||||
|
if (!hi) return Fail("bad \\u escape");
|
||||||
|
char32_t cp = *hi;
|
||||||
|
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||||
|
// High surrogate: a low surrogate must follow.
|
||||||
|
if (i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
|
||||||
|
const std::size_t save = i;
|
||||||
|
i += 2;
|
||||||
|
auto lo = Hex4();
|
||||||
|
if (lo && *lo >= 0xDC00 && *lo <= 0xDFFF) {
|
||||||
|
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
|
||||||
|
} else {
|
||||||
|
i = save;
|
||||||
|
cp = 0xFFFD; // lone high surrogate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cp = 0xFFFD;
|
||||||
|
}
|
||||||
|
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||||
|
cp = 0xFFFD; // stray low surrogate
|
||||||
|
}
|
||||||
|
AppendUtf8(out, cp);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: return Fail("unknown escape");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unescaped control characters are invalid JSON; rejecting them
|
||||||
|
// keeps a truncated/corrupted file from parsing as valid.
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) return Fail("control character in string");
|
||||||
|
out += c;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8259 number grammar, validated explicitly:
|
||||||
|
//
|
||||||
|
// number = [ "-" ] int [ frac ] [ exp ]
|
||||||
|
// int = "0" / ( digit1-9 *DIGIT )
|
||||||
|
// frac = "." 1*DIGIT
|
||||||
|
// exp = ("e"/"E") [ "-" / "+" ] 1*DIGIT
|
||||||
|
//
|
||||||
|
// Scanning the character set and handing the span to from_chars is NOT
|
||||||
|
// equivalent: from_chars accepts "01" and "+1", both of which are invalid
|
||||||
|
// JSON. Since the point of this parser is to reject corrupted input rather
|
||||||
|
// than guess at it, the grammar is checked before conversion.
|
||||||
|
std::expected<double, ParseError> ParseNumber() {
|
||||||
|
const std::size_t start = i;
|
||||||
|
auto digit = [&] { return i < s.size() && s[i] >= '0' && s[i] <= '9'; };
|
||||||
|
|
||||||
|
if (i < s.size() && s[i] == '-') ++i; // leading '+' is not JSON
|
||||||
|
|
||||||
|
if (!digit()) return Fail("expected digit");
|
||||||
|
if (s[i] == '0') {
|
||||||
|
++i;
|
||||||
|
// "0" may not be followed by another digit — "01" is invalid.
|
||||||
|
if (digit()) return Fail("leading zero");
|
||||||
|
} else {
|
||||||
|
while (digit()) ++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < s.size() && s[i] == '.') {
|
||||||
|
++i;
|
||||||
|
if (!digit()) return Fail("expected digit after '.'");
|
||||||
|
while (digit()) ++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < s.size() && (s[i] == 'e' || s[i] == 'E')) {
|
||||||
|
++i;
|
||||||
|
if (i < s.size() && (s[i] == '+' || s[i] == '-')) ++i;
|
||||||
|
if (!digit()) return Fail("expected digit in exponent");
|
||||||
|
while (digit()) ++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
double out = 0;
|
||||||
|
const char* b = s.data() + start;
|
||||||
|
const char* e = s.data() + i;
|
||||||
|
const auto [ptr, ec] = std::from_chars(b, e, out);
|
||||||
|
// Out-of-range is the one case the grammar allows but the type can't
|
||||||
|
// hold (1e400). Treat it as malformed rather than silently infinite.
|
||||||
|
if (ec != std::errc{} || ptr != e) return Fail("number out of range");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursion is bounded so a hostile or corrupted document can't blow the
|
||||||
|
// stack. Real input here nests two levels (array of flat objects).
|
||||||
|
ParseResult ParseValue(int depth) {
|
||||||
|
if (depth > 32) return Fail("nesting too deep");
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i >= s.size()) return Fail("unexpected end of input");
|
||||||
|
|
||||||
|
Value v;
|
||||||
|
const char c = s[i];
|
||||||
|
|
||||||
|
if (c == '"') {
|
||||||
|
auto str = ParseString();
|
||||||
|
if (!str) return std::unexpected(str.error());
|
||||||
|
v.type = Type::String;
|
||||||
|
v.string = std::move(*str);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (c == '{') {
|
||||||
|
++i;
|
||||||
|
v.type = Type::Object;
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
||||||
|
while (true) {
|
||||||
|
SkipWhitespace();
|
||||||
|
auto key = ParseString();
|
||||||
|
if (!key) return std::unexpected(key.error());
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i >= s.size() || s[i] != ':') return Fail("expected ':'");
|
||||||
|
++i;
|
||||||
|
auto val = ParseValue(depth + 1);
|
||||||
|
if (!val) return std::unexpected(val.error());
|
||||||
|
v.object.emplace_back(std::move(*key), std::move(*val));
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
||||||
|
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
||||||
|
return Fail("expected ',' or '}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c == '[') {
|
||||||
|
++i;
|
||||||
|
v.type = Type::Array;
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
||||||
|
while (true) {
|
||||||
|
auto item = ParseValue(depth + 1);
|
||||||
|
if (!item) return std::unexpected(item.error());
|
||||||
|
v.array.push_back(std::move(*item));
|
||||||
|
SkipWhitespace();
|
||||||
|
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
||||||
|
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
||||||
|
return Fail("expected ',' or ']'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Literal("true")) { v.type = Type::Bool; v.boolean = true; return v; }
|
||||||
|
if (Literal("false")) { v.type = Type::Bool; v.boolean = false; return v; }
|
||||||
|
if (Literal("null")) { v.type = Type::Null; return v; }
|
||||||
|
|
||||||
|
auto num = ParseNumber();
|
||||||
|
if (!num) return std::unexpected(num.error());
|
||||||
|
v.type = Type::Number;
|
||||||
|
v.number = *num;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Parse a complete JSON document. Trailing content after the top-level value
|
||||||
|
// is an error rather than ignored — it usually means a truncated or
|
||||||
|
// concatenated file, and silently accepting the prefix hides that.
|
||||||
|
export ParseResult Parse(std::string_view text) {
|
||||||
|
Parser p{ text, 0 };
|
||||||
|
auto v = p.ParseValue(0);
|
||||||
|
if (!v) return v;
|
||||||
|
p.SkipWhitespace();
|
||||||
|
if (p.i != text.size()) {
|
||||||
|
return std::unexpected(ParseError{ "trailing content after JSON value", p.i });
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Json
|
||||||
330
shared/interfaces/Catcrafts.Shared-Model.cppm
Normal file
330
shared/interfaces/Catcrafts.Shared-Model.cppm
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Content types, and loaders for the two files that stay data.
|
||||||
|
//
|
||||||
|
// The split: content a person authors (products, projects, legal pages,
|
||||||
|
// demos) is compiled in — see :Content — so a typo in it is a build error,
|
||||||
|
// not a silently blank page. Content a machine writes at build time stays
|
||||||
|
// JSON under content/: posts.json (fetched from the fediverse by CI) and
|
||||||
|
// rates.json (ECB rates). Those change without anyone editing C++, so their
|
||||||
|
// loaders live here.
|
||||||
|
//
|
||||||
|
// Both hosts call the same loaders on the same bytes; only where the bytes
|
||||||
|
// come from differs. The wasm build gets the files through Crafter.Build's
|
||||||
|
// VFS (cfg.files -> files.json -> fetched before _start, readable at the
|
||||||
|
// bundle root); the native server reads content/ off disk. So the loaders
|
||||||
|
// take text, not paths.
|
||||||
|
//
|
||||||
|
// Loading never fails hard. These files are generated by our own CI, so a
|
||||||
|
// missing or wrong-typed field is a generator bug, and a page rendering with
|
||||||
|
// one bad card beats a page that refuses to render at all. Malformed JSON is
|
||||||
|
// the one exception — that yields an empty list, because a half-parsed
|
||||||
|
// document is worse than none.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Model;
|
||||||
|
import std;
|
||||||
|
import :Json;
|
||||||
|
|
||||||
|
namespace Catcrafts {
|
||||||
|
|
||||||
|
// One image or video belonging to a post.
|
||||||
|
//
|
||||||
|
// `src` is a local path under /media once tools/fetch-media.sh has mirrored it.
|
||||||
|
// It can still be an absolute URL if that download failed, which is why the
|
||||||
|
// renderer puts it through Url() rather than assuming it is site-relative.
|
||||||
|
//
|
||||||
|
// Width and height come from ffprobe at mirror time and exist to stop layout
|
||||||
|
// shift: without them the browser cannot reserve space, and the text below every
|
||||||
|
// card jumps as each file arrives.
|
||||||
|
export struct PostMedia {
|
||||||
|
std::string src;
|
||||||
|
std::string kind; // "image" | "video"
|
||||||
|
// Still frame for a video, from the instance's own thumbnail. Without it a
|
||||||
|
// <video preload="metadata"> shows a black box until the visitor presses
|
||||||
|
// play — and these posts are their video, so the black box is the page.
|
||||||
|
// Empty for images, and empty when the instance generated no thumbnail.
|
||||||
|
std::string poster;
|
||||||
|
std::int64_t width = 0;
|
||||||
|
std::int64_t height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A post mirrored from the fediverse (Lemmy). Deliberately not the full post:
|
||||||
|
// the body stays on the community's instance, where the comments are. We show
|
||||||
|
// enough to be worth clicking and then hand off — no crawling, no comment
|
||||||
|
// mirroring, no markdown pipeline.
|
||||||
|
//
|
||||||
|
// `permalink` is resolved at fetch time to the COMMUNITY's instance, not the
|
||||||
|
// author's. Both host a federated copy; the community's is where the discussion
|
||||||
|
// actually is, and it is what the site should be pointing readers at.
|
||||||
|
export struct Post {
|
||||||
|
std::string title;
|
||||||
|
std::string permalink; // canonical ap_id on the instance; where "discuss" goes
|
||||||
|
std::string linkUrl; // for link posts, the linked target; empty otherwise
|
||||||
|
std::string community; // e.g. "linux@lemmy.ml"
|
||||||
|
std::string published; // ISO-8601, as emitted by the API
|
||||||
|
std::string excerpt; // plain text, truncated by CI — never markdown
|
||||||
|
std::int64_t score = 0;
|
||||||
|
std::int64_t comments = 0;
|
||||||
|
std::vector<PostMedia> media;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An entry on the projects page. Repo content, not a database — these change
|
||||||
|
// on the order of months.
|
||||||
|
//
|
||||||
|
// Deliberately shallow: name, one sentence, a link. The Forgejo page is the
|
||||||
|
// canonical home for details, so anything richer here (status, feature lists)
|
||||||
|
// just drifts out of date against it.
|
||||||
|
export struct Project {
|
||||||
|
std::string name;
|
||||||
|
std::string blurb;
|
||||||
|
std::string url;
|
||||||
|
std::string language;
|
||||||
|
bool featured = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the document <head> needs. Emitted server-side once SSR lands;
|
||||||
|
// until then catcrafts-head.js sets the title and the rest is unused but
|
||||||
|
// carried so the renderers don't need changing later.
|
||||||
|
export struct PageMeta {
|
||||||
|
std::string title;
|
||||||
|
std::string description;
|
||||||
|
std::string canonical;
|
||||||
|
std::string ogType = "website";
|
||||||
|
std::string ogImage;
|
||||||
|
bool noindex = false;
|
||||||
|
// >0 emits <meta http-equiv="refresh"> — the no-JavaScript way for a page
|
||||||
|
// to track changing server state. Used by the order page while a payment
|
||||||
|
// is pending; leave 0 everywhere content is static.
|
||||||
|
int refreshSeconds = 0;
|
||||||
|
// Emits the inline timezone hint script (see RenderDocument): pages that
|
||||||
|
// show the dual EU/export price set this so the browser can move the
|
||||||
|
// emphasis onto whichever price applies locally. Presentation only — both
|
||||||
|
// prices are always in the markup, the HTML is identical for every
|
||||||
|
// visitor, and nothing is detected server-side.
|
||||||
|
bool geoPriceHint = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct Spec {
|
||||||
|
std::string label;
|
||||||
|
std::string value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A buyable variation of a product — currently colour. Each carries its own
|
||||||
|
// VAT-inclusive price because the supplier prices them differently (white
|
||||||
|
// costs ~€90 more wholesale than green). The ex-VAT export price is always
|
||||||
|
// DERIVED (Money::NetFromGross), never stored.
|
||||||
|
export struct Variant {
|
||||||
|
std::string slug; // "green" — form value and order-record field
|
||||||
|
std::string label; // "Green" — what the buyer reads
|
||||||
|
std::int64_t priceInclMinor = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct Product {
|
||||||
|
std::string slug;
|
||||||
|
std::string name;
|
||||||
|
std::string tagline;
|
||||||
|
// "available" — buyable now. "coming-soon" — listed with launch prices,
|
||||||
|
// orders not open yet. "unavailable" — listed but not sellable (sourcing
|
||||||
|
// gap, price swing). In both closed states the page stays up, the buy
|
||||||
|
// form does not, and the checkout POST is refused server-side.
|
||||||
|
std::string status;
|
||||||
|
// The EU consumer price, VAT-inclusive, in cents. With variants present
|
||||||
|
// this is the FROM price (cheapest variant) and is kept in sync by the
|
||||||
|
// loader; without variants it is simply the price.
|
||||||
|
std::int64_t priceInclMinor = 0;
|
||||||
|
std::vector<Variant> variants;
|
||||||
|
std::string currency = "EUR";
|
||||||
|
// Flat shipping per zone, in cents, consumer-facing (VAT-inclusive where
|
||||||
|
// VAT applies). The fallback when Sendcloud has no rate for a country —
|
||||||
|
// live carrier rates take precedence wherever they exist.
|
||||||
|
std::int64_t shipNlMinor = 0;
|
||||||
|
std::int64_t shipEuMinor = 0;
|
||||||
|
std::int64_t shipWorldMinor = 0;
|
||||||
|
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
|
||||||
|
// from our own origin like every other asset — the privacy notice's
|
||||||
|
// "everything comes from catcrafts.net" applies to product images too.
|
||||||
|
std::string image;
|
||||||
|
std::string summary;
|
||||||
|
std::string warranty;
|
||||||
|
// Rendered as a prominent warning box when non-empty. Exists for exactly
|
||||||
|
// one thing today: the emergency-calling caveat — the single claim on the
|
||||||
|
// page that is about safety rather than features, which is why it gets a
|
||||||
|
// warning box instead of a table row.
|
||||||
|
std::string safetyNote;
|
||||||
|
std::vector<Spec> specs;
|
||||||
|
|
||||||
|
bool Buyable() const { return status == "available" && priceInclMinor > 0; }
|
||||||
|
bool ComingSoon() const { return status == "coming-soon"; }
|
||||||
|
|
||||||
|
// nullptr for a colour we never listed — the checkout rejects rather than
|
||||||
|
// guessing, so a tampered form value cannot buy an unpriced variant.
|
||||||
|
const Variant* FindVariant(std::string_view vslug) const {
|
||||||
|
for (const Variant& v : variants) {
|
||||||
|
if (v.slug == vslug) return &v;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
// The default selection: the cheapest variant, which is also what the
|
||||||
|
// "from" price shows — so the page never advertises a number the default
|
||||||
|
// choice doesn't honour.
|
||||||
|
const Variant* CheapestVariant() const {
|
||||||
|
const Variant* best = nullptr;
|
||||||
|
for (const Variant& v : variants) {
|
||||||
|
if (!best || v.priceInclMinor < best->priceInclMinor) best = &v;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ECB euro reference rates, baked in at build time by tools/fetch-rates.sh.
|
||||||
|
// Values are integer micro-units of target currency per euro (1 EUR = 1.0834
|
||||||
|
// USD -> 1'083'400) — the script does the decimal-to-integer conversion so no
|
||||||
|
// float ever touches a money path here. Used ONLY for the indicative national-
|
||||||
|
// currency line on the order page; every charge is in euros.
|
||||||
|
export struct Rates {
|
||||||
|
std::string date; // ECB publication date
|
||||||
|
std::vector<std::pair<std::string, std::int64_t>> microPerEur;
|
||||||
|
|
||||||
|
std::int64_t Find(std::string_view code) const {
|
||||||
|
for (const auto& [k, v] : microPerEur) {
|
||||||
|
if (k == code) return v;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export Rates LoadRates(std::string_view json) {
|
||||||
|
Rates out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return out;
|
||||||
|
out.date = std::string(doc->Str("date"));
|
||||||
|
if (const Json::Value* m = doc->Find("micro_per_eur"); m && m->IsObject()) {
|
||||||
|
for (const auto& [k, v] : m->object) {
|
||||||
|
if (v.type == Json::Type::Number && v.number > 0) {
|
||||||
|
out.microPerEur.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything the order status page needs to render — a projection of the
|
||||||
|
// server's order record, not the record itself. The renderer stays a pure
|
||||||
|
// function in Shared; the server owns storage and fills this in.
|
||||||
|
export struct OrderView {
|
||||||
|
std::string token; // the capability that IS the URL — never logged
|
||||||
|
std::string reference; // short human code, quoted in the bank transfer
|
||||||
|
std::string status; // "awaiting_payment" | "paid" | "shipped" | "cancelled"
|
||||||
|
std::string productName;
|
||||||
|
std::string colorLabel; // "Forest Green", empty for variantless products
|
||||||
|
std::int64_t quantity = 1;
|
||||||
|
std::int64_t unitMinor = 0;
|
||||||
|
std::string payUrl; // bunq.me link; empty once paid or when cancelled
|
||||||
|
std::string createdAt; // ISO 8601, shown verbatim
|
||||||
|
std::string country;
|
||||||
|
std::int64_t goodsMinor = 0;
|
||||||
|
std::int64_t shippingMinor = 0;
|
||||||
|
std::int64_t totalMinor = 0;
|
||||||
|
bool vatIncluded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An entry on the demos page.
|
||||||
|
//
|
||||||
|
// `needsWasm` is what decides whether a page ships the ~239 KB module, so it is
|
||||||
|
// data rather than a hardcoded route check: adding a demo that needs the
|
||||||
|
// renderer, or one that does not, should not require touching the server.
|
||||||
|
export struct Demo {
|
||||||
|
std::string slug;
|
||||||
|
std::string name;
|
||||||
|
std::string blurb;
|
||||||
|
std::string tech;
|
||||||
|
std::string needs; // what the browser must support, in plain words ("requires" is a keyword)
|
||||||
|
std::string mountId; // element id the renderer reparents its canvas into
|
||||||
|
std::string aspect; // CSS aspect-ratio for the mount box
|
||||||
|
bool needsWasm = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A legal / informational page. Sections of headed paragraphs rather than
|
||||||
|
// markdown: these are written once and read rarely, and a prose format would
|
||||||
|
// mean carrying a markdown renderer for four pages.
|
||||||
|
export struct LegalSection {
|
||||||
|
std::string heading;
|
||||||
|
std::vector<std::string> body;
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct LegalPage {
|
||||||
|
std::string slug;
|
||||||
|
std::string title;
|
||||||
|
std::string updated; // ISO date, shown to the reader
|
||||||
|
std::string lede;
|
||||||
|
std::vector<LegalSection> sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── loaders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export std::vector<Post> LoadPosts(std::string_view json) {
|
||||||
|
std::vector<Post> out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsArray()) return out;
|
||||||
|
out.reserve(doc->array.size());
|
||||||
|
for (const Json::Value& item : doc->array) {
|
||||||
|
if (!item.IsObject()) continue;
|
||||||
|
Post p;
|
||||||
|
p.title = std::string(item.Str("title"));
|
||||||
|
p.permalink = std::string(item.Str("permalink"));
|
||||||
|
p.linkUrl = std::string(item.Str("url"));
|
||||||
|
p.community = std::string(item.Str("community"));
|
||||||
|
p.published = std::string(item.Str("published"));
|
||||||
|
p.excerpt = std::string(item.Str("excerpt"));
|
||||||
|
p.score = item.Int("score");
|
||||||
|
p.comments = item.Int("comments");
|
||||||
|
if (const Json::Value* m = item.Find("media"); m && m->IsArray()) {
|
||||||
|
for (const Json::Value& mv : m->array) {
|
||||||
|
if (!mv.IsObject()) continue;
|
||||||
|
PostMedia pm;
|
||||||
|
pm.src = std::string(mv.Str("src"));
|
||||||
|
pm.kind = std::string(mv.Str("kind", "image"));
|
||||||
|
pm.poster = std::string(mv.Str("poster"));
|
||||||
|
pm.width = mv.Int("w");
|
||||||
|
pm.height = mv.Int("h");
|
||||||
|
// Only the two kinds the renderer knows how to emit. Anything
|
||||||
|
// else would fall through to an <img> for a file that is not an
|
||||||
|
// image, so treat it as image only when it says so.
|
||||||
|
if (pm.kind != "image" && pm.kind != "video") pm.kind = "image";
|
||||||
|
if (pm.src.empty()) continue;
|
||||||
|
p.media.push_back(std::move(pm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A post with no title and nowhere to click is not renderable; drop it
|
||||||
|
// rather than emit an empty card.
|
||||||
|
if (p.title.empty() && p.permalink.empty()) continue;
|
||||||
|
out.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── formatting helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// "2026-07-18T14:03:22.123456Z" -> "2026-07-18".
|
||||||
|
//
|
||||||
|
// Deliberately not a date parser. The only thing the UI needs is the day, the
|
||||||
|
// API always emits ISO-8601, and pulling in civil-time handling to render ten
|
||||||
|
// characters would be the wrong trade. Anything unexpected is passed through
|
||||||
|
// unchanged so a format change shows up as visibly odd text rather than a
|
||||||
|
// silently wrong date.
|
||||||
|
export std::string_view DateOnly(std::string_view iso) {
|
||||||
|
if (iso.size() >= 10 && iso[4] == '-' && iso[7] == '-') return iso.substr(0, 10);
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts
|
||||||
214
shared/interfaces/Catcrafts.Shared-Money.cppm
Normal file
214
shared/interfaces/Catcrafts.Shared-Money.cppm
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
The source code of this website is made available for viewing purposes only.
|
||||||
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Money and VAT arithmetic, in integer minor units. No floats, ever: a double
|
||||||
|
// cannot represent 0.01 exactly, and a price that drifts by a cent between the
|
||||||
|
// page, the payment request and the invoice is a bookkeeping bug you find at
|
||||||
|
// tax time. Everything here is exact integer math with explicit rounding.
|
||||||
|
//
|
||||||
|
// Lives in Catcrafts.Shared (imports std only) so the same arithmetic renders
|
||||||
|
// the price on the page and computes the amount actually charged — one
|
||||||
|
// function, so they cannot disagree.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Money;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Catcrafts::Money {
|
||||||
|
|
||||||
|
// NL standard VAT rate, in basis points. Prices are stored VAT-inclusive (EU
|
||||||
|
// Price Indication Directive: consumers must see the final price), and the net
|
||||||
|
// is derived — not the other way round — so the advertised number is exact and
|
||||||
|
// the derived one takes the rounding.
|
||||||
|
export inline constexpr std::int64_t kVatRateBp = 2100;
|
||||||
|
|
||||||
|
// Net (ex-VAT) amount from a VAT-inclusive gross, rounding half up on the
|
||||||
|
// division. gross = net * (1 + rate) exactly when working in real numbers;
|
||||||
|
// in minor units the net absorbs the sub-cent remainder.
|
||||||
|
export constexpr std::int64_t NetFromGross(std::int64_t grossMinor,
|
||||||
|
std::int64_t rateBp = kVatRateBp) {
|
||||||
|
// net = gross * 10000 / (10000 + rate), rounded half up.
|
||||||
|
const std::int64_t denom = 10000 + rateBp;
|
||||||
|
return (grossMinor * 10000 + denom / 2) / denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: the VAT-inclusive price that NETS a given ex-VAT cost.
|
||||||
|
// This is how "cost plus, eat nothing" survives VAT: a carrier rate of €7.13
|
||||||
|
// ex VAT must be charged as €8.63 inclusive, or the remitted VAT comes out of
|
||||||
|
// the margin. Half-up like its sibling, and the pair round-trips (GrossFromNet
|
||||||
|
// then NetFromGross returns the original cost).
|
||||||
|
export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
|
||||||
|
std::int64_t rateBp = kVatRateBp) {
|
||||||
|
return (netMinor * (10000 + rateBp) + 5000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "580.00" — the wire format bunq's amount objects use, and the unambiguous
|
||||||
|
// way to show cents. Always two decimals, no thousands separator.
|
||||||
|
export std::string FormatMinor(std::int64_t minor) {
|
||||||
|
const bool neg = minor < 0;
|
||||||
|
if (neg) minor = -minor;
|
||||||
|
return std::format("{}{}.{:02}", neg ? "-" : "", minor / 100, minor % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display form: "€580" when the cents are zero, "€479.34" otherwise. Whole
|
||||||
|
// prices are chosen deliberately (no .99 games), so showing ".00" everywhere
|
||||||
|
// would just add noise to the number that matters.
|
||||||
|
export std::string FormatEuro(std::int64_t minor) {
|
||||||
|
if (minor % 100 == 0 && minor >= 0) return std::format("€{}", minor / 100);
|
||||||
|
return "€" + FormatMinor(minor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EU membership decides VAT treatment: inside the EU the price is charged
|
||||||
|
// VAT-inclusive; outside, the sale is a zero-rated export and the buyer's own
|
||||||
|
// customs channel collects import VAT and duty. ISO 3166-1 alpha-2, uppercase.
|
||||||
|
//
|
||||||
|
// Note NIR/GB: the UK left; Northern Ireland's special goods status is not
|
||||||
|
// modelled — GB is simply non-EU here, which is the correct default for a
|
||||||
|
// consumer parcel.
|
||||||
|
export bool IsEuCountry(std::string_view cc) {
|
||||||
|
static constexpr std::array<std::string_view, 27> eu{
|
||||||
|
"AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
||||||
|
"FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL",
|
||||||
|
"PT", "RO", "SE", "SI", "SK",
|
||||||
|
};
|
||||||
|
return std::ranges::find(eu, cc) != eu.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shipping zones. Three tiers is deliberate — real carrier pricing has more
|
||||||
|
// distinctions than anyone wants in a checkout, and the tiers only need to be
|
||||||
|
// roughly right because the rates are set per product with margin.
|
||||||
|
export enum class Zone { Nl, Eu, World };
|
||||||
|
|
||||||
|
export Zone ZoneFor(std::string_view cc) {
|
||||||
|
if (cc == "NL") return Zone::Nl;
|
||||||
|
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One order's money, fully derived. `goods` is what the buyer pays for the
|
||||||
|
// device: the VAT-inclusive price inside the EU, the derived net outside it.
|
||||||
|
// `vatCharged` is what the total contains in Dutch VAT — zero for exports —
|
||||||
|
// kept because the invoice needs it, not because the page shows it.
|
||||||
|
export struct Totals {
|
||||||
|
std::int64_t goods = 0;
|
||||||
|
std::int64_t shipping = 0;
|
||||||
|
std::int64_t total = 0;
|
||||||
|
std::int64_t vatCharged = 0;
|
||||||
|
bool vatIncluded = false; // true when `goods` includes EU VAT
|
||||||
|
};
|
||||||
|
|
||||||
|
// The single authority on what an order costs. The checkout handler calls this
|
||||||
|
// with the buyer's country; nothing about the amount ever comes from the
|
||||||
|
// client. `shippingMinor` arrives already resolved (live carrier table or the
|
||||||
|
// zone fallback — the server decides which), so this function stays pure.
|
||||||
|
//
|
||||||
|
// The export net is derived from the LINE total (unit × qty), not per unit —
|
||||||
|
// rounding per line is the invoice-correct convention, and it is also the
|
||||||
|
// formula the checkout preview script mirrors, so the preview and the charge
|
||||||
|
// cannot drift by a cent.
|
||||||
|
export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
|
||||||
|
std::int64_t shippingMinor,
|
||||||
|
std::string_view country) {
|
||||||
|
Totals t;
|
||||||
|
t.shipping = shippingMinor;
|
||||||
|
const std::int64_t lineGross = unitGrossMinor * quantity;
|
||||||
|
if (IsEuCountry(country)) {
|
||||||
|
t.goods = lineGross;
|
||||||
|
t.vatIncluded = true;
|
||||||
|
// VAT applies to the shipping too — it is part of the taxable supply.
|
||||||
|
const std::int64_t taxable = t.goods + t.shipping;
|
||||||
|
t.vatCharged = taxable - NetFromGross(taxable);
|
||||||
|
} else {
|
||||||
|
t.goods = NetFromGross(lineGross);
|
||||||
|
t.vatIncluded = false;
|
||||||
|
t.vatCharged = 0;
|
||||||
|
}
|
||||||
|
t.total = t.goods + t.shipping;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zone-table shipping fallback, used when no live carrier table covers the
|
||||||
|
// destination. Exported separately so the same lookup renders the shipping
|
||||||
|
// table on the product page.
|
||||||
|
export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu,
|
||||||
|
std::int64_t shipWorld, std::string_view country) {
|
||||||
|
const Zone z = ZoneFor(country);
|
||||||
|
return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── indicative currency display ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Orders are charged in euros, always — bunq collects EUR and the invoice is
|
||||||
|
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
||||||
|
// their card will actually take, so the order page also shows an INDICATIVE
|
||||||
|
// conversion in the buyer's national currency, from ECB reference rates baked
|
||||||
|
// in at build time. Indicative is the whole contract: the buyer's bank sets
|
||||||
|
// the real conversion rate, and the page says so next to the number.
|
||||||
|
|
||||||
|
export struct Currency {
|
||||||
|
std::string_view code; // ISO 4217
|
||||||
|
std::string_view symbol; // display prefix, e.g. "CA$"
|
||||||
|
};
|
||||||
|
|
||||||
|
// One supported non-euro display currency and its representative country.
|
||||||
|
// `cc` matters beyond lookup: IsEuCountry(cc) decides which euro amount a
|
||||||
|
// conversion starts from — an EU member's currency (SEK, PLN, …) converts the
|
||||||
|
// VAT-inclusive price, everyone else's converts the ex-VAT export price.
|
||||||
|
export struct CurrencyRow {
|
||||||
|
std::string_view cc;
|
||||||
|
Currency cur;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only currencies the ECB publishes reference rates for; anywhere else shows
|
||||||
|
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
|
||||||
|
// is noise.
|
||||||
|
export std::span<const CurrencyRow> AllCurrencies() {
|
||||||
|
static constexpr std::array<CurrencyRow, 16> rows{{
|
||||||
|
{ "US", { "USD", "US$" } },
|
||||||
|
{ "CA", { "CAD", "CA$" } },
|
||||||
|
{ "GB", { "GBP", "£" } },
|
||||||
|
{ "CH", { "CHF", "CHF " } },
|
||||||
|
{ "NO", { "NOK", "kr " } },
|
||||||
|
{ "SE", { "SEK", "kr " } },
|
||||||
|
{ "DK", { "DKK", "kr " } },
|
||||||
|
{ "PL", { "PLN", "zł " } },
|
||||||
|
{ "CZ", { "CZK", "Kč " } },
|
||||||
|
{ "HU", { "HUF", "Ft " } },
|
||||||
|
{ "RO", { "RON", "lei " } },
|
||||||
|
{ "BG", { "BGN", "лв " } },
|
||||||
|
{ "AU", { "AUD", "A$" } },
|
||||||
|
{ "NZ", { "NZD", "NZ$" } },
|
||||||
|
{ "JP", { "JPY", "¥" } },
|
||||||
|
{ "IS", { "ISK", "kr " } },
|
||||||
|
}};
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency for a destination country, or nullopt for euro countries and
|
||||||
|
// anywhere unsupported.
|
||||||
|
export std::optional<Currency> CurrencyFor(std::string_view cc) {
|
||||||
|
for (const CurrencyRow& r : AllCurrencies()) {
|
||||||
|
if (r.cc == cc) return r.cur;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert cents-EUR to WHOLE units of the target currency, half-up. Whole
|
||||||
|
// units on purpose: a number that is explicitly approximate should not carry
|
||||||
|
// two decimals of false precision. `rateMicro` is target-per-euro in millionths
|
||||||
|
// (1 EUR = 1.0834 USD -> 1'083'400).
|
||||||
|
export constexpr std::int64_t ConvertIndicative(std::int64_t minorEur,
|
||||||
|
std::int64_t rateMicro) {
|
||||||
|
// units = minorEur/100 * rateMicro/1e6, rounded half up.
|
||||||
|
return (minorEur * rateMicro + 50'000'000) / 100'000'000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "≈ CA$920" — the display form of an indicative conversion.
|
||||||
|
export std::string FormatIndicative(const Currency& cur, std::int64_t wholeUnits) {
|
||||||
|
return std::format("≈ {}{}", cur.symbol, wholeUnits);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Money
|
||||||
212
shared/interfaces/Catcrafts.Shared-Route.cppm
Normal file
212
shared/interfaces/Catcrafts.Shared-Route.cppm
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// URL -> route mapping, shared by both hosts.
|
||||||
|
//
|
||||||
|
// The wasm app resolves the route from window.location; the native server will
|
||||||
|
// resolve it from the request target. Same function, so a URL cannot mean one
|
||||||
|
// thing to a crawler and another to the app — which is the whole reason route
|
||||||
|
// parsing lives here instead of in an if/else chain per host.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared:Route;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Catcrafts {
|
||||||
|
|
||||||
|
export enum class RouteKind {
|
||||||
|
Home,
|
||||||
|
Projects,
|
||||||
|
Posts,
|
||||||
|
Demos, // /demos — the list
|
||||||
|
Demo, // /demos/<slug>
|
||||||
|
Shop, // /shop — the (currently single-item) product list
|
||||||
|
Product, // /shop/<slug>
|
||||||
|
Order, // /order/<token> — an order's status page
|
||||||
|
Invoice, // /order/<token>/invoice.md — the signed invoice download
|
||||||
|
Legal, // /legal/<slug> — privacy, imprint, terms
|
||||||
|
// The blog these routes replace. sitemap.xml advertised /blog and
|
||||||
|
// /blog/<slug>, and those URLs are in the wild — in shared links and in
|
||||||
|
// whatever the crawlers already have. They resolve to Posts and carry a
|
||||||
|
// canonical target so each host can do the right thing: the app rewrites
|
||||||
|
// the address bar, the server will answer 301.
|
||||||
|
LegacyBlog,
|
||||||
|
NotFound,
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct Route {
|
||||||
|
RouteKind kind = RouteKind::NotFound;
|
||||||
|
std::string path; // normalised, no trailing slash (except "/")
|
||||||
|
std::string query; // raw, including leading '?', or empty
|
||||||
|
// Non-empty when the request should be canonicalised to a different URL.
|
||||||
|
std::string canonicalRedirect;
|
||||||
|
// The <slug> of /shop/<slug>, empty for every other route.
|
||||||
|
std::string slug;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An order token is the entire capability to view that order — whoever has the
|
||||||
|
// URL sees the status page. 32 lowercase hex characters (128 bits from the
|
||||||
|
// server's CSPRNG), so it is unguessable; constraining the shape here means a
|
||||||
|
// probe like /order/../../etc never reaches a lookup.
|
||||||
|
export bool IsOrderToken(std::string_view s) {
|
||||||
|
if (s.size() != 32) return false;
|
||||||
|
for (const char c : s) {
|
||||||
|
const bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slug appears in a URL, in an element id, and as a form field, so it is
|
||||||
|
// constrained rather than sanitised at each use: lowercase, digits and hyphens
|
||||||
|
// only, and bounded. Anything else is not a slug we ever generated.
|
||||||
|
export bool IsValidSlug(std::string_view s) {
|
||||||
|
if (s.empty() || s.size() > 64) return false;
|
||||||
|
for (const char c : s) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
// Leading/trailing hyphens and doubled hyphens are not produced by
|
||||||
|
// anything here, so reject them rather than carry ambiguity around.
|
||||||
|
if (s.front() == '-' || s.back() == '-') return false;
|
||||||
|
return s.find("--") == std::string_view::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a trailing slash so "/projects/" and "/projects" are one route rather
|
||||||
|
// than two URLs with identical content — which would otherwise be a duplicate
|
||||||
|
// canonical for crawlers.
|
||||||
|
export std::string_view NormalisePath(std::string_view path) {
|
||||||
|
while (path.size() > 1 && path.back() == '/') path.remove_suffix(1);
|
||||||
|
if (path.empty()) return "/";
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export Route ParseRoute(std::string_view path, std::string_view query = {}) {
|
||||||
|
Route r;
|
||||||
|
const std::string_view p = NormalisePath(path);
|
||||||
|
r.path = std::string(p);
|
||||||
|
r.query = std::string(query);
|
||||||
|
|
||||||
|
if (p == "/") { r.kind = RouteKind::Home; return r; }
|
||||||
|
if (p == "/projects") { r.kind = RouteKind::Projects; return r; }
|
||||||
|
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
|
||||||
|
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
|
||||||
|
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
|
||||||
|
|
||||||
|
// /order/<token>. The token is validated structurally here for the same
|
||||||
|
// reason slugs are: nothing downstream should ever see one it must
|
||||||
|
// re-validate. An invalid token is a plain 404 — indistinguishable from an
|
||||||
|
// unknown one, so the URL shape leaks nothing.
|
||||||
|
if (p.starts_with("/order/")) {
|
||||||
|
std::string_view rest = p.substr(7);
|
||||||
|
// /order/<token>/invoice.md downloads the signed invoice. Parsed here
|
||||||
|
// (not in the handler) so the token shape check happens exactly once.
|
||||||
|
RouteKind kind = RouteKind::Order;
|
||||||
|
if (rest.ends_with("/invoice.md")) {
|
||||||
|
rest.remove_suffix(11);
|
||||||
|
kind = RouteKind::Invoice;
|
||||||
|
}
|
||||||
|
if (IsOrderToken(rest)) {
|
||||||
|
r.kind = kind;
|
||||||
|
r.slug = std::string(rest);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.kind = RouteKind::NotFound;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /shop/<slug>. An invalid slug is a 404 rather than a lookup with a
|
||||||
|
// rejected key, so nothing downstream ever sees a slug it must re-validate.
|
||||||
|
if (p.starts_with("/shop/")) {
|
||||||
|
const std::string_view slug = p.substr(6);
|
||||||
|
if (IsValidSlug(slug)) {
|
||||||
|
r.kind = RouteKind::Product;
|
||||||
|
r.slug = std::string(slug);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.kind = RouteKind::NotFound;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.starts_with("/demos/")) {
|
||||||
|
const std::string_view slug = p.substr(7);
|
||||||
|
if (IsValidSlug(slug)) {
|
||||||
|
r.kind = RouteKind::Demo;
|
||||||
|
r.slug = std::string(slug);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.kind = RouteKind::NotFound;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /demo was the single-demo URL before there was a list. Keep it working:
|
||||||
|
// it was linked from the home page and may be in someone's history.
|
||||||
|
if (p == "/demo") {
|
||||||
|
r.kind = RouteKind::Demos;
|
||||||
|
r.canonicalRedirect = "/demos";
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.starts_with("/legal/")) {
|
||||||
|
const std::string_view slug = p.substr(7);
|
||||||
|
if (IsValidSlug(slug)) {
|
||||||
|
r.kind = RouteKind::Legal;
|
||||||
|
r.slug = std::string(slug);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.kind = RouteKind::NotFound;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /blog, /blog/anything -> /posts
|
||||||
|
if (p == "/blog" || p.starts_with("/blog/")) {
|
||||||
|
r.kind = RouteKind::LegacyBlog;
|
||||||
|
r.canonicalRedirect = "/posts";
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
r.kind = RouteKind::NotFound;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nav entries, in order. Shared so the header and the sitemap cannot
|
||||||
|
// disagree about what pages exist.
|
||||||
|
export struct NavItem {
|
||||||
|
std::string_view label;
|
||||||
|
std::string_view href;
|
||||||
|
RouteKind kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export std::span<const NavItem> NavItems() {
|
||||||
|
static constexpr std::array<NavItem, 5> items{{
|
||||||
|
{ "Home", "/", RouteKind::Home },
|
||||||
|
{ "Shop", "/shop", RouteKind::Shop },
|
||||||
|
{ "Projects", "/projects", RouteKind::Projects },
|
||||||
|
{ "Posts", "/posts", RouteKind::Posts },
|
||||||
|
{ "Demos", "/demos", RouteKind::Demos },
|
||||||
|
}};
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes that belong in sitemap.xml. NotFound and LegacyBlog are excluded:
|
||||||
|
// one isn't a page, the other is a redirect, and advertising either invites
|
||||||
|
// crawlers to index a URL that isn't canonical.
|
||||||
|
export std::span<const std::string_view> SitemapPaths() {
|
||||||
|
// /shop/<slug> entries are appended by the caller from the loaded product
|
||||||
|
// list — the sitemap has to reflect what actually exists, and a hardcoded
|
||||||
|
// slug list here would be one more thing to forget to update.
|
||||||
|
static constexpr std::array<std::string_view, 8> paths{
|
||||||
|
"/", "/shop", "/projects", "/posts", "/demos",
|
||||||
|
// Legal pages are indexable on purpose: they are trust signals, and a
|
||||||
|
// buyer looking for the returns policy before purchasing should be able
|
||||||
|
// to find it from a search engine.
|
||||||
|
"/legal/privacy", "/legal/terms", "/legal/imprint",
|
||||||
|
};
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts
|
||||||
1269
shared/interfaces/Catcrafts.Shared-Views.cppm
Normal file
1269
shared/interfaces/Catcrafts.Shared-Views.cppm
Normal file
File diff suppressed because it is too large
Load diff
41
shared/interfaces/Catcrafts.Shared.cppm
Normal file
41
shared/interfaces/Catcrafts.Shared.cppm
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
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.Shared — the target-neutral half of the site.
|
||||||
|
//
|
||||||
|
// Everything here compiles for BOTH wasm32-wasip1 (the browser app) and the
|
||||||
|
// host triple (the server that will render the same pages for crawlers and
|
||||||
|
// no-JS clients). That is the entire point: one set of page renderers, not
|
||||||
|
// two that drift.
|
||||||
|
//
|
||||||
|
// THE RULE: this module imports `std` and its own partitions. Nothing else.
|
||||||
|
//
|
||||||
|
// Not a style preference — Crafter.Build's dependency scanner does not respect
|
||||||
|
// `#ifdef` around `import` statements (Crafter.Graphics/project.cpp:116-121
|
||||||
|
// documents the same constraint), so an `#ifdef`-guarded
|
||||||
|
// `import Crafter.Graphics;` in here would still force Crafter.Graphics onto
|
||||||
|
// the native build, where it cannot compile. There is no conditional-import
|
||||||
|
// escape hatch, so the boundary has to be absolute.
|
||||||
|
//
|
||||||
|
// Consequently Catcrafts.Shared is a pure function:
|
||||||
|
//
|
||||||
|
// (route, data) -> RenderedPage
|
||||||
|
//
|
||||||
|
// Each host does its own I/O — fetch/DOM on wasm, sockets/SQLite on native —
|
||||||
|
// and calls in with plain data.
|
||||||
|
|
||||||
|
export module Catcrafts.Shared;
|
||||||
|
|
||||||
|
export import :Html;
|
||||||
|
export import :Json;
|
||||||
|
export import :Form;
|
||||||
|
export import :Model;
|
||||||
|
export import :Content;
|
||||||
|
export import :Money;
|
||||||
|
export import :Route;
|
||||||
|
export import :Views;
|
||||||
18
sitemap.xml
18
sitemap.xml
|
|
@ -1,18 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://catcrafts.net/</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://catcrafts.net/blog</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://catcrafts.net/blog/hello-world</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://catcrafts.net/blog/in-wasm-exit-doesnt-mean-done</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://catcrafts.net/blog/hello-world-2</loc>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
1341
styles/styles.css
1341
styles/styles.css
File diff suppressed because it is too large
Load diff
204
tools/dev.sh
Executable file
204
tools/dev.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Run the whole site locally, in the same shape as production.
|
||||||
|
#
|
||||||
|
# tools/dev.sh build both products and serve on :8080
|
||||||
|
# tools/dev.sh --no-build use whatever is already in bin/
|
||||||
|
#
|
||||||
|
# Why this exists: catcrafts-server serves PAGES only. Static assets — the wasm
|
||||||
|
# module, styles.css, the JS bridges — are Caddy's job in production, so running
|
||||||
|
# the server on its own gives you correct HTML with no stylesheet, which looks
|
||||||
|
# broken and isn't. This starts both and puts Caddy in front, so what you see
|
||||||
|
# locally is what the deployed site does, including the reverse-proxy split and
|
||||||
|
# the scoped cross-origin headers.
|
||||||
|
#
|
||||||
|
# Ctrl-C stops both.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BUILD=1
|
||||||
|
[ "${1:-}" = "--no-build" ] && BUILD=0
|
||||||
|
|
||||||
|
PORT="${DEV_PORT:-8080}"
|
||||||
|
BACKEND_PORT="${DEV_BACKEND_PORT:-8081}"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
|
||||||
|
# Refuse to start if either port is taken.
|
||||||
|
#
|
||||||
|
# Without this, a leftover instance from an earlier run keeps serving: the new
|
||||||
|
# backend fails to bind, the old Caddy carries on proxying to the OLD binary, and
|
||||||
|
# the site looks like the build did not take effect. That has wasted real time
|
||||||
|
# twice — the symptom (stale content) points at the build, not at a process.
|
||||||
|
port_busy() {
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -ltn 2>/dev/null | grep -qE "[:.]$1 "
|
||||||
|
else
|
||||||
|
curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$1/" 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
for _p in "$PORT" "$BACKEND_PORT"; do
|
||||||
|
if port_busy "$_p"; then
|
||||||
|
echo "dev: port $_p is already in use — another instance is probably still running." >&2
|
||||||
|
echo "dev: find it with: ss -ltnp | grep -E ':(8080|8081) '" >&2
|
||||||
|
echo "dev: then kill those PIDs, or set DEV_PORT / DEV_BACKEND_PORT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
|
||||||
|
[ -n "${CADDY_PID:-}" ] && kill "$CADDY_PID" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Exactly one match or fail loudly. A variant directory name embeds a config
|
||||||
|
# hash, so two matches means the tree holds artifacts from two different
|
||||||
|
# configurations and picking either would be a coin flip — this has caused real
|
||||||
|
# confusion (testing a stale binary and believing the result).
|
||||||
|
onedir() {
|
||||||
|
_m=$(find bin -maxdepth 1 -type d -name "$1" 2>/dev/null | sort)
|
||||||
|
_n=$(printf '%s\n' "$_m" | grep -c . || true)
|
||||||
|
if [ "$_n" -ne 1 ]; then
|
||||||
|
echo "dev: expected exactly one $1 directory under bin/, found $_n" >&2
|
||||||
|
[ "$_n" -gt 1 ] && printf '%s\n' "$_m" >&2
|
||||||
|
echo "dev: run 'rm -rf bin' and try again" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$_m"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$BUILD" = 1 ]; then
|
||||||
|
echo "dev: building the server product..."
|
||||||
|
crafter-build --local -- --product=server >"$WORK/build-server.log" 2>&1 \
|
||||||
|
|| { echo "dev: server build failed:" >&2; tail -20 "$WORK/build-server.log" >&2; exit 1; }
|
||||||
|
SRV=$(onedir 'Catcrafts.Server-*')
|
||||||
|
|
||||||
|
# sitemap.xml and feed.xml are generated from the same route table and Post
|
||||||
|
# model the pages use, and the wasm build copies them into the bundle — so
|
||||||
|
# they have to exist before it runs.
|
||||||
|
"$SRV/catcrafts-server" --sitemap > sitemap.xml
|
||||||
|
"$SRV/catcrafts-server" --feed > feed.xml
|
||||||
|
|
||||||
|
echo "dev: building the wasm bundle..."
|
||||||
|
crafter-build --local >"$WORK/build-web.log" 2>&1 \
|
||||||
|
|| { echo "dev: wasm build failed:" >&2; tail -20 "$WORK/build-web.log" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRV=$(onedir 'Catcrafts.Server-*')
|
||||||
|
WEB=$(onedir 'Catcrafts.Net-*')
|
||||||
|
|
||||||
|
# Makes the static shell survive being served at a deep URL. Run unconditionally,
|
||||||
|
# not just after a build: --no-build may be pointing at a bundle someone produced
|
||||||
|
# with a bare crafter-build, and the script is idempotent.
|
||||||
|
./tools/fix-bundle-depth.sh "$WEB" >/dev/null
|
||||||
|
|
||||||
|
ABS_WEB="$(cd "$WEB" && pwd)"
|
||||||
|
# Mirrored post media. Absent is fine — the pages render, the media 404s — so
|
||||||
|
# this does not block running the site before fetch-media.sh has been run.
|
||||||
|
mkdir -p media
|
||||||
|
PWD_MEDIA="$(cd media && pwd)"
|
||||||
|
|
||||||
|
# Mirrors deploy/Caddyfile.example: static assets from disk, everything else
|
||||||
|
# proxied to the backend, cross-origin isolation only on the paths that boot the
|
||||||
|
# wasm module.
|
||||||
|
cat > "$WORK/Caddyfile" <<EOF
|
||||||
|
:$PORT {
|
||||||
|
root * $ABS_WEB
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js /catcrafts-head.js /files.json /variants.json /*.wgsl
|
||||||
|
header @isolated {
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Embedder-Policy "require-corp"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js /catcrafts-head.js /files.json /variants.json /styles.css /favicon.svg /robots.txt /*.wgsl /*.jpg /posts.json /rates.json
|
||||||
|
handle @static {
|
||||||
|
header Cache-Control "no-store"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /media/* {
|
||||||
|
root * $PWD_MEDIA
|
||||||
|
header Cache-Control "no-store"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:$BACKEND_PORT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Rail selection for dev:
|
||||||
|
# * a repo-root .env (gitignored, never committed) is sourced if present —
|
||||||
|
# put MOLLIE_API_KEY=test_… there to point dev at Mollie's real test mode;
|
||||||
|
# * DEV_RAIL=fake|mollie overrides the automatic choice;
|
||||||
|
# * default with no key is the fake rail: full order lifecycle, no network.
|
||||||
|
# "Pay" an order with: touch $WORK/orders.jsonl.fake-paid
|
||||||
|
#
|
||||||
|
# A live_ key is refused outright. Dev creates throwaway orders; pointing them
|
||||||
|
# at real money collection is never what anyone meant.
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a; . ./.env; set +a
|
||||||
|
fi
|
||||||
|
RAIL="${DEV_RAIL:-}"
|
||||||
|
if [ -z "$RAIL" ]; then
|
||||||
|
RAIL=fake
|
||||||
|
[ -n "${MOLLIE_API_KEY:-}" ] && RAIL=mollie
|
||||||
|
fi
|
||||||
|
if [ "$RAIL" = mollie ]; then
|
||||||
|
case "${MOLLIE_API_KEY:-}" in
|
||||||
|
test_*) echo "dev: payments via Mollie TEST mode" ;;
|
||||||
|
live_*) echo "dev: refusing to run dev against a LIVE Mollie key." >&2
|
||||||
|
echo "dev: live keys belong in /etc/catcrafts/payments.env on the server." >&2
|
||||||
|
exit 1 ;;
|
||||||
|
*) echo "dev: MOLLIE_API_KEY is not a test_ or live_ key" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$SRV/catcrafts-server" --serve "$BACKEND_PORT" \
|
||||||
|
--orders="$WORK/orders.jsonl" --rail="$RAIL" \
|
||||||
|
--redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 &
|
||||||
|
SRV_PID=$!
|
||||||
|
|
||||||
|
caddy run --config "$WORK/Caddyfile" --adapter caddyfile >"$WORK/caddy.log" 2>&1 &
|
||||||
|
CADDY_PID=$!
|
||||||
|
|
||||||
|
# Wait for the front door rather than sleeping a fixed amount.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 100 ]; do
|
||||||
|
curl -s -o /dev/null "http://127.0.0.1:$PORT/api/healthz" 2>/dev/null && break
|
||||||
|
i=$((i + 1)); sleep 0.1
|
||||||
|
done
|
||||||
|
if [ "$i" -ge 100 ]; then
|
||||||
|
echo "dev: did not come up. server log:" >&2; cat "$WORK/server.log" >&2
|
||||||
|
echo "dev: caddy log:" >&2; cat "$WORK/caddy.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
catcrafts.net is running: http://localhost:$PORT
|
||||||
|
|
||||||
|
/ home
|
||||||
|
/shop product list
|
||||||
|
/shop/fp6-pmos product page + checkout (fake payment rail)
|
||||||
|
/projects the Crafter suite
|
||||||
|
/posts fediverse posts
|
||||||
|
/demos demo list; /demos/raytracer loads the wasm
|
||||||
|
/legal/privacy privacy notice
|
||||||
|
/feed.xml Atom feed
|
||||||
|
/media/* mirrored post media (run tools/fetch-media.sh to populate)
|
||||||
|
|
||||||
|
Orders from this session go to a temp file and are discarded on exit.
|
||||||
|
Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with:
|
||||||
|
touch $WORK/orders.jsonl.fake-paid")
|
||||||
|
Ctrl-C to stop.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Surface backend output as it happens — this is where a render error shows up.
|
||||||
|
tail -f "$WORK/server.log" &
|
||||||
|
wait "$SRV_PID"
|
||||||
709
tools/e2e.sh
Executable file
709
tools/e2e.sh
Executable file
|
|
@ -0,0 +1,709 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# End-to-end HTTP tests against a running catcrafts-server.
|
||||||
|
#
|
||||||
|
# Why this exists separately from --selftest: that one covers the pure
|
||||||
|
# functions (escaping, JSON, form validation) in-process. This covers the
|
||||||
|
# things only a real request can show — status codes, headers, redirects,
|
||||||
|
# form submission, and whether a page is actually complete without
|
||||||
|
# JavaScript. Those are exactly the properties that matter at launch and the
|
||||||
|
# ones a unit test cannot observe.
|
||||||
|
#
|
||||||
|
# Runs the server itself on a scratch port with a temporary orders file and the
|
||||||
|
# FAKE payment rail, so it never touches real data, never dials Mollie, and needs
|
||||||
|
# no setup. The fake rail makes the whole order lifecycle testable: it hands
|
||||||
|
# out pretend payment links, and reports "paid" once the marker file exists —
|
||||||
|
# which is how these tests simulate the customer paying.
|
||||||
|
#
|
||||||
|
# usage: tools/e2e.sh [path-to-catcrafts-server]
|
||||||
|
#
|
||||||
|
# Exits non-zero on the first failure, so it works as a CI gate.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SERVER="${1:-}"
|
||||||
|
if [ -z "$SERVER" ]; then
|
||||||
|
SERVER=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort | head -n1)/catcrafts-server
|
||||||
|
fi
|
||||||
|
[ -x "$SERVER" ] || { echo "e2e: server binary not found or not executable: $SERVER" >&2; exit 1; }
|
||||||
|
|
||||||
|
PORT="${E2E_PORT:-8199}"
|
||||||
|
BASE="http://127.0.0.1:$PORT"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
ORDERS="$WORK/orders.jsonl"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Deterministic environment: a developer shell that sourced the repo .env
|
||||||
|
# must not leak real provider keys into the test server — live Sendcloud
|
||||||
|
# rates would silently change the shipping totals asserted below.
|
||||||
|
unset MOLLIE_API_KEY BUNQ_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true
|
||||||
|
|
||||||
|
# An ephemeral GPG key so invoice signing runs the REAL signing path and the
|
||||||
|
# suite can verify the signature. gpg is required (CI installs gnupg with the
|
||||||
|
# base tools); a missing binary should fail loudly, not skip silently.
|
||||||
|
export GNUPGHOME="$WORK/gnupg"
|
||||||
|
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
|
||||||
|
gpg --batch --passphrase '' --quick-gen-key 'Catcrafts e2e <invoices@e2e.invalid>' \
|
||||||
|
default default never >/dev/null 2>&1 \
|
||||||
|
|| { echo "e2e: could not create a GPG key (is gnupg installed?)" >&2; exit 1; }
|
||||||
|
export INVOICE_GPG_KEY='invoices@e2e.invalid'
|
||||||
|
|
||||||
|
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 &
|
||||||
|
SRV_PID=$!
|
||||||
|
|
||||||
|
# Wait for the listener rather than sleeping a fixed amount: a fixed sleep is
|
||||||
|
# either too short on a loaded machine or wasted time on a fast one.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 100 ]; do
|
||||||
|
if curl -s -o /dev/null "$BASE/api/healthz" 2>/dev/null; then break; fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [ "$i" -ge 100 ]; then
|
||||||
|
echo "e2e: server did not come up on $PORT" >&2
|
||||||
|
cat "$WORK/server.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||||||
|
bad() { fail=$((fail + 1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||||
|
# Counted and reported separately, never as a pass: a check that silently did not
|
||||||
|
# run is how a suite ends up reporting green over an untested code path.
|
||||||
|
skip() { skipped=$((skipped + 1)); printf ' SKIP %s\n %s\n' "$1" "$2"; }
|
||||||
|
|
||||||
|
# status <path> <expected> [method] [data]
|
||||||
|
status() {
|
||||||
|
_p="$1"; _want="$2"; _m="${3:-GET}"; _d="${4:-}"
|
||||||
|
if [ "$_m" = POST ]; then
|
||||||
|
_got=$(curl -s -o /dev/null -w '%{http_code}' -X POST -d "$_d" "$BASE$_p")
|
||||||
|
elif [ "$_m" = HEAD ]; then
|
||||||
|
# --head, not -X HEAD: with -X curl still waits for a response body
|
||||||
|
# that a correct HEAD reply never sends, and hangs until timeout.
|
||||||
|
_got=$(curl -s -o /dev/null -w '%{http_code}' --head "$BASE$_p")
|
||||||
|
else
|
||||||
|
_got=$(curl -s -o /dev/null -w '%{http_code}' -X "$_m" "$BASE$_p")
|
||||||
|
fi
|
||||||
|
[ "$_got" = "$_want" ] && ok "$_m $_p -> $_want" \
|
||||||
|
|| bad "$_m $_p" "expected $_want, got $_got"
|
||||||
|
}
|
||||||
|
|
||||||
|
# body_has <path> <string> <label>
|
||||||
|
body_has() {
|
||||||
|
if curl -s "$BASE$1" | grep -qF -- "$2"; then ok "$3"; else bad "$3" "missing: $2"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# body_lacks <path> <string> <label>
|
||||||
|
body_lacks() {
|
||||||
|
if curl -s "$BASE$1" | grep -qF -- "$2"; then bad "$3" "unexpectedly present: $2"; else ok "$3"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# header_has <path> <regex> <label>
|
||||||
|
header_has() {
|
||||||
|
if curl -sD- -o /dev/null "$BASE$1" | grep -qiE -- "$2"; then ok "$3"; else bad "$3" "no header matching: $2"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== status codes =="
|
||||||
|
for p in / /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
|
||||||
|
/legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do
|
||||||
|
status "$p" 200
|
||||||
|
done
|
||||||
|
# Trailing slashes must normalise, not 404 or duplicate the canonical URL.
|
||||||
|
status /projects/ 200
|
||||||
|
status /shop/ 200
|
||||||
|
# A real 404, which a client-side router cannot produce — this is the whole
|
||||||
|
# reason the backend exists.
|
||||||
|
status /nope 404
|
||||||
|
status /shop/nope 404
|
||||||
|
status /legal/nope 404
|
||||||
|
# A slug that cannot be one of ours is rejected before any lookup.
|
||||||
|
status /shop/BAD--slug 404
|
||||||
|
status /demos/nope 404
|
||||||
|
# The retired blog URLs are still in the wild; they must redirect, not 404.
|
||||||
|
status /blog 301
|
||||||
|
status /blog/hello-world 301
|
||||||
|
# /demo was the single-demo URL before there was a list; it must redirect, not
|
||||||
|
# 404, because it was linked from the home page.
|
||||||
|
status /demo 301
|
||||||
|
|
||||||
|
echo "== redirects =="
|
||||||
|
if curl -sD- -o /dev/null "$BASE/blog/hello-world" | grep -qi '^location: */posts'; then
|
||||||
|
ok "/blog/* sends Location: /posts"
|
||||||
|
else
|
||||||
|
bad "/blog/* Location header" "not /posts"
|
||||||
|
fi
|
||||||
|
if curl -sD- -o /dev/null "$BASE/demo" | grep -qi '^location: */demos'; then
|
||||||
|
ok "/demo sends Location: /demos"
|
||||||
|
else
|
||||||
|
bad "/demo Location header" "not /demos"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== the no-JavaScript guarantee =="
|
||||||
|
# The site must be complete without the wasm module. If these fail, the SSR
|
||||||
|
# work has regressed and crawlers see an empty page again.
|
||||||
|
body_has /projects "imsd" "/projects has content in the HTML"
|
||||||
|
body_has /projects "<title>Projects" "/projects has a real title"
|
||||||
|
body_lacks /projects "<script" "/projects ships no script at all"
|
||||||
|
body_lacks /legal/privacy "<script" "/legal/privacy ships no script"
|
||||||
|
|
||||||
|
# Placeholders are dev-only markers; one reaching production is a content bug
|
||||||
|
# (an imprint that says PLACEHOLDER once shipped exactly that way).
|
||||||
|
for pg in /legal/privacy /legal/terms /legal/imprint /shop/fp6-pmos; do
|
||||||
|
body_lacks "$pg" 'PLACEHOLDER' "$pg ships no placeholder markers"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Shop pages are the one exception to script-free: they carry exactly ONE
|
||||||
|
# inline script — the timezone price hint. Pin its shape hard: inline only (no
|
||||||
|
# src=, so nothing external can ever ride in under this exception), no network
|
||||||
|
# APIs, and the page must remain complete without it — both prices in the
|
||||||
|
# markup regardless.
|
||||||
|
for pg in /shop /shop/fp6-pmos; do
|
||||||
|
n=$(curl -s "$BASE$pg" | grep -c '<script' || true)
|
||||||
|
if [ "$n" = 1 ]; then
|
||||||
|
ok "$pg carries exactly one script (the price hint)"
|
||||||
|
else
|
||||||
|
bad "$pg script count" "expected 1, got $n"
|
||||||
|
fi
|
||||||
|
if curl -s "$BASE$pg" | grep -qE '<script[^>]*src='; then
|
||||||
|
bad "$pg script" "an external script crept in under the inline exception"
|
||||||
|
else
|
||||||
|
ok "$pg script is inline, not external"
|
||||||
|
fi
|
||||||
|
if curl -s "$BASE$pg" | grep -oE '<script>.*</script>' | grep -qE 'fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon'; then
|
||||||
|
bad "$pg script" "the price hint makes network calls"
|
||||||
|
else
|
||||||
|
ok "$pg script makes no network calls"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome"
|
||||||
|
body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too"
|
||||||
|
|
||||||
|
# The shop card: one euro number as the crawler/no-JS text, every supported
|
||||||
|
# currency pre-formatted server-side as a data attribute for the script to
|
||||||
|
# pick from. Converted amounts carry "~". CAD converts the ex-VAT price;
|
||||||
|
# SEK (an EU member's currency) converts the VAT-inclusive price.
|
||||||
|
body_has /shop 'class="price__single"' "shop card renders the single-number price"
|
||||||
|
body_has /shop 'data-cad="~CA$' "shop card carries a CAD conversion"
|
||||||
|
body_has /shop 'data-sek="~kr ' "shop card carries an SEK conversion"
|
||||||
|
body_has /shop 'data-world="€465.54"' "shop card carries the euro export fallback"
|
||||||
|
# The product page gets the same headline element, so a Canadian sees ~CA$
|
||||||
|
# at the top there too, and the buy card states the customs position plainly.
|
||||||
|
body_has /shop/fp6-pmos 'data-cad="~CA$' "product page headline carries the conversion"
|
||||||
|
body_has /shop/fp6-pmos 'indicative only' "buy card says converted prices are indicative"
|
||||||
|
body_has /shop/fp6-pmos 'customs authority' "buy card names whose problem import charges are"
|
||||||
|
body_lacks /shop/fp6-pmos 'collected on arrival' "the vague customs phrasing is gone"
|
||||||
|
# The label must not claim the Dutch rate is an EU-wide one.
|
||||||
|
body_lacks /shop/fp6-pmos 'EU VAT' "price label does not call 21% an EU-wide rate"
|
||||||
|
# The renderer loads only where a demo entry declares needsWasm — the demo LIST
|
||||||
|
# is a content page and must stay free of it.
|
||||||
|
body_has /demos/raytracer "catcrafts.wasm" "/demos/raytracer loads the wasm"
|
||||||
|
body_lacks /demos "<script" "/demos itself ships no script"
|
||||||
|
body_has /demos/raytracer 'id="webgpu-demo"' "raytracer page has the mount element"
|
||||||
|
# Exactly one chrome root: the wasm adopts the server's, never builds a second.
|
||||||
|
if [ "$(curl -s "$BASE/demos/raytracer" | grep -c 'id="catcrafts-root"')" = 1 ]; then
|
||||||
|
ok "raytracer page has exactly one chrome root"
|
||||||
|
else
|
||||||
|
bad "raytracer chrome root count" "expected 1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== SSR / wasm head interaction =="
|
||||||
|
# catcrafts-head.js used to set document.title unconditionally, which replaced
|
||||||
|
# the server's per-route title with the generic site name and appended a second
|
||||||
|
# stylesheet, favicon and viewport tag. The <meta name="cc-ssr"> marker is what
|
||||||
|
# it now checks; if that marker stops being emitted the guard silently stops
|
||||||
|
# working, so assert it is present and that the head is not duplicated.
|
||||||
|
body_has /demos/raytracer 'name="cc-ssr"' "SSR marker present for head.js to detect"
|
||||||
|
body_has /demos/raytracer '<title>Real-time ray tracer' "demo page keeps its route-specific title"
|
||||||
|
for probe in 'rel="stylesheet"' 'rel="icon"' 'name="viewport"'; do
|
||||||
|
n=$(curl -s "$BASE/demos/raytracer" | grep -o "$probe" | wc -l)
|
||||||
|
if [ "$n" = 1 ]; then ok "demo page has exactly one $probe"
|
||||||
|
else bad "demo page $probe count" "expected 1, got $n"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== wasm boots at depth =="
|
||||||
|
# The bug this section exists for: /demos/raytracer is two segments deep, and
|
||||||
|
# every asset the runtime needs was referenced RELATIVE to the document —
|
||||||
|
# src="runtime.js", fetch("files.json"), fetch("variants.json"), and the .wasm
|
||||||
|
# named by variants.json. So the browser asked for /demos/runtime.js, Caddy's
|
||||||
|
# try_files handed back index.html, and the module was blocked for being
|
||||||
|
# text/html. Four NS_ERROR_CORRUPTED_CONTENT failures and a blank demo.
|
||||||
|
#
|
||||||
|
# The server emits <base href="/"> on any page that boots wasm, which fixes all
|
||||||
|
# of them at once. These checks pin that, and pin the precondition that makes it
|
||||||
|
# safe: nothing else on the page may use a relative URL.
|
||||||
|
boot=$(curl -s "$BASE/demos/raytracer" | grep -c '<script src=' || true)
|
||||||
|
if [ "$boot" -eq 0 ]; then
|
||||||
|
skip "wasm boot checks" "no bundle under bin/, so no boot scripts were emitted — build the wasm product first"
|
||||||
|
else
|
||||||
|
body_has /demos/raytracer '<base href="/">' "wasm page sets <base href=\"/\">"
|
||||||
|
# Absolute script srcs regardless of the <base>, so the tags stay correct even
|
||||||
|
# if the base is ever removed.
|
||||||
|
if curl -s "$BASE/demos/raytracer" | grep -qE '<script[[:space:]][^>]*src="[^"/:]'; then
|
||||||
|
bad "boot script paths" "a script src is relative and will 404 at depth"
|
||||||
|
curl -s "$BASE/demos/raytracer" | grep -oE '<script[^>]*src="[^"]*"' >&2
|
||||||
|
else
|
||||||
|
ok "every boot script src is absolute"
|
||||||
|
fi
|
||||||
|
# A <base> rewrites every relative URL in the document, so it is only safe
|
||||||
|
# while there are none. If a view ever emits href="x" or a bare "#frag", the
|
||||||
|
# base silently retargets it — assert the precondition rather than trusting it.
|
||||||
|
rel=$(curl -s "$BASE/demos/raytracer" \
|
||||||
|
| grep -oE '(href|src|action)="[^"]*"' \
|
||||||
|
| grep -cvE '="(/|https?://|mailto:)' || true)
|
||||||
|
if [ "$rel" -eq 0 ]; then
|
||||||
|
ok "wasm page has no relative URL for <base> to retarget"
|
||||||
|
else
|
||||||
|
bad "relative URLs under <base>" "$rel URL(s) would be retargeted by the base tag"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# The base tag belongs only where the runtime needs it. On a content page it is
|
||||||
|
# dead weight and one more thing that could retarget a future relative link.
|
||||||
|
body_lacks /posts '<base' "/posts has no base tag"
|
||||||
|
body_lacks /shop/fp6-pmos '<base' "/shop/<slug> has no base tag"
|
||||||
|
|
||||||
|
echo "== home page actions =="
|
||||||
|
body_has / 'Browse projects' "home links to projects"
|
||||||
|
body_has / 'Browse shop' "home links to the shop"
|
||||||
|
body_lacks / 'ray tracer' "home no longer pushes the ray tracer"
|
||||||
|
|
||||||
|
echo "== post media =="
|
||||||
|
# The media IS the content of these posts (screen recordings of the work), and it
|
||||||
|
# must come from our own origin: the privacy notice states that everything the
|
||||||
|
# browser loads comes from catcrafts.net, and a third-party embed would send
|
||||||
|
# every visitor's IP to whichever instance hosted the file.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE '<(img|video) class="post-media__item"'; then
|
||||||
|
ok "/posts embeds its media"
|
||||||
|
else
|
||||||
|
bad "/posts media" "no embedded media found"
|
||||||
|
fi
|
||||||
|
# `poster` is in the list because a video poster is fetched on page load exactly
|
||||||
|
# like an <img> src is, so a third-party poster leaks the same visitor IP.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE '(src|href|poster)="https?://[^"]*\.(mp4|webm|webp|png|jpe?g|gif)'; then
|
||||||
|
bad "/posts media origin" "media loaded from a third party"
|
||||||
|
else
|
||||||
|
ok "/posts loads no media from a third party"
|
||||||
|
fi
|
||||||
|
# Dimensions prevent layout shift as each file arrives. Needs ffprobe at fetch
|
||||||
|
# time (see the CI package list) — a build host without it produces no
|
||||||
|
# dimensions at all, which is what this catches.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE '<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then
|
||||||
|
ok "images carry width/height"
|
||||||
|
else
|
||||||
|
bad "image dimensions" "no width/height on embedded images"
|
||||||
|
fi
|
||||||
|
# Videos too. This assertion exists because they silently lost theirs: ffprobe
|
||||||
|
# appends an empty CSV field for some files, so parsing `width,height` as one
|
||||||
|
# joined string yielded a height of "480x" and the guard discarded both.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then
|
||||||
|
ok "videos carry width/height"
|
||||||
|
else
|
||||||
|
bad "video dimensions" "no width/height on embedded videos"
|
||||||
|
fi
|
||||||
|
# A poster is the frame shown before anyone presses play, and these posts ARE
|
||||||
|
# their video. Asserting "at least one" rather than "every one": an instance that
|
||||||
|
# generated no thumbnail is a legitimate empty poster, but zero posters across
|
||||||
|
# every video means the fetch/mirror/render chain is broken.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*poster="/media/'; then
|
||||||
|
ok "videos carry a locally-hosted poster"
|
||||||
|
else
|
||||||
|
bad "video poster" "no video has a poster; a black box shows until play"
|
||||||
|
fi
|
||||||
|
# preload="metadata", not auto: several 5 MB recordings must not all download on
|
||||||
|
# page load.
|
||||||
|
if curl -s "$BASE/posts" | grep -q 'preload="metadata"'; then
|
||||||
|
ok "video does not preload its whole body"
|
||||||
|
else
|
||||||
|
bad "video preload" "expected preload=\"metadata\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== headers =="
|
||||||
|
header_has / 'x-content-type-options: *nosniff' "nosniff on pages"
|
||||||
|
header_has / 'cache-control: *public' "pages are cacheable"
|
||||||
|
header_has /nope 'x-robots-tag: *noindex' "404 is noindex"
|
||||||
|
header_has /feed.xml 'content-type: *application/atom' "feed content-type"
|
||||||
|
header_has /sitemap.xml 'content-type: *application/xml' "sitemap content-type"
|
||||||
|
|
||||||
|
echo "== sitemap and feed content =="
|
||||||
|
body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product"
|
||||||
|
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
|
||||||
|
body_has /sitemap.xml "/demos" "sitemap lists the demos page"
|
||||||
|
|
||||||
|
echo "== instance-agnostic copy =="
|
||||||
|
# The account lives on one instance but posts go into communities on others, so
|
||||||
|
# no page should name a specific instance as though it were the home of the
|
||||||
|
# discussion.
|
||||||
|
# In visible text, not in href values — a post's own permalink necessarily
|
||||||
|
# contains an instance name, and that is not what this is about. Strip tags and
|
||||||
|
# check the prose.
|
||||||
|
for pg in / /posts /shop; do
|
||||||
|
if curl -s "$BASE$pg" | sed 's/<[^>]*>/ /g' | grep -qi 'ani\.social'; then
|
||||||
|
bad "$pg names an instance in visible text" "found ani.social in prose"
|
||||||
|
else
|
||||||
|
ok "$pg names no specific instance in visible text"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
body_has /posts "fediverse" "/posts refers to the fediverse generally"
|
||||||
|
# The fediverse account is not advertised at all — only individual posts are.
|
||||||
|
body_lacks / "/u/" "footer does not link a fediverse profile"
|
||||||
|
body_lacks /posts "/u/" "/posts links no account profile, only threads"
|
||||||
|
|
||||||
|
# Every outbound thread link is a real permalink: absolute https, on some
|
||||||
|
# instance, pointing at a numeric post id. fetch-posts.sh resolves these against
|
||||||
|
# the COMMUNITY's instance rather than the author's, because that is where the
|
||||||
|
# discussion is — but a resolution failure legitimately falls back to the
|
||||||
|
# author's copy, so this checks the shape rather than naming a host.
|
||||||
|
links=$(curl -s "$BASE/posts" | grep -oE 'href="https://[a-z0-9.-]+/post/[0-9]+"' | wc -l)
|
||||||
|
if [ "$links" -gt 0 ]; then
|
||||||
|
ok "/posts links $links threads by permalink"
|
||||||
|
else
|
||||||
|
bad "post permalinks" "no https://<instance>/post/<id> link found"
|
||||||
|
fi
|
||||||
|
# Nothing should link a post by a bare id or a relative path — that would mean a
|
||||||
|
# permalink was rendered without its origin and silently resolves to catcrafts.net.
|
||||||
|
if curl -s "$BASE/posts" | grep -qE 'href="/post/[0-9]+"'; then
|
||||||
|
bad "post permalinks" "a thread link lost its instance and points at us"
|
||||||
|
else
|
||||||
|
ok "no thread link resolves to catcrafts.net"
|
||||||
|
fi
|
||||||
|
body_lacks /sitemap.xml "/blog" "sitemap does not advertise the redirect"
|
||||||
|
body_lacks /sitemap.xml "/order" "sitemap does not advertise order pages"
|
||||||
|
body_has /feed.xml "<feed xmlns=\"http://www.w3.org/2005/Atom\">" "feed is Atom"
|
||||||
|
|
||||||
|
# Open shop or coming-soon? The pricing blob (data-cc) exists only on the real
|
||||||
|
# order form, so its presence is the probe. The checkout, order-lifecycle and
|
||||||
|
# invoice suites below only run when the shop is open; the coming-soon branch
|
||||||
|
# asserts the closed state instead. Launch day (status flip to "available" in
|
||||||
|
# Catcrafts.Shared-Content.cppm) re-arms the full suite with no e2e edit.
|
||||||
|
if curl -s "$BASE/shop/fp6-pmos" | grep -q 'data-cc='; then SHOP_OPEN=1; else SHOP_OPEN=0; fi
|
||||||
|
|
||||||
|
echo "== the shop front =="
|
||||||
|
# The price is rendered from the same integers the checkout charges, with the
|
||||||
|
# derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
||||||
|
body_has /shop/fp6-pmos '€563.30' "product page shows the from-price (green supplier + €50)"
|
||||||
|
body_has /shop/fp6-pmos '€465.54' "product page shows the derived ex-VAT price"
|
||||||
|
body_has /shop/fp6-pmos '>from<' "product page marks the price as a from-price"
|
||||||
|
body_has /shop '€563.30' "shop card shows the from-price"
|
||||||
|
# Every colour is priced in the selector, and the form carries the exact data
|
||||||
|
# blob the preview computes from.
|
||||||
|
body_has /shop/fp6-pmos 'Black — €569.30' "colour selector prices black"
|
||||||
|
body_has /shop/fp6-pmos 'White — €654.88' "colour selector prices white"
|
||||||
|
if [ "$SHOP_OPEN" = 1 ]; then
|
||||||
|
body_has /shop/fp6-pmos 'data-cc=' "form embeds the pricing blob"
|
||||||
|
body_has /shop/fp6-pmos 'id="cc-total"' "live total element present"
|
||||||
|
else
|
||||||
|
body_has /shop/fp6-pmos 'Coming soon' "coming-soon notice on the buy panel"
|
||||||
|
body_has /shop 'coming soon' "shop card carries the coming-soon badge"
|
||||||
|
body_lacks /shop/fp6-pmos '<form' "no order form while coming soon"
|
||||||
|
fi
|
||||||
|
body_has /shop/fp6-pmos 'src="/fp6-pmos.jpg"' "product page embeds the photo"
|
||||||
|
body_has /shop 'src="/fp6-pmos.jpg"' "shop card embeds the thumbnail"
|
||||||
|
# The image file itself is Caddy's to serve (static asset), so its presence is
|
||||||
|
# asserted against the repo, not this server.
|
||||||
|
if [ -f images/fp6-pmos.jpg ]; then
|
||||||
|
ok "product photo exists in the repo"
|
||||||
|
else
|
||||||
|
bad "product photo" "images/fp6-pmos.jpg missing"
|
||||||
|
fi
|
||||||
|
body_has /shop/fp6-pmos 'not yet verified' "emergency-calling caveat is on the page"
|
||||||
|
body_lacks /shop 'reservation' "no reservation copy survives on /shop"
|
||||||
|
body_lacks /shop/fp6-pmos 'Reserve one' "no reservation form survives"
|
||||||
|
|
||||||
|
GOOD='email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl'
|
||||||
|
|
||||||
|
if [ "$SHOP_OPEN" = 1 ]; then
|
||||||
|
|
||||||
|
echo "== checkout =="
|
||||||
|
|
||||||
|
# A valid submission answers 303 straight to the PAYMENT page — no interim
|
||||||
|
# stop. The fake rail's payUrl is the order page itself, so the token is
|
||||||
|
# still extractable from the Location and the browser flow works in dev.
|
||||||
|
LOC=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d "$GOOD" "$BASE/shop/fp6-pmos")
|
||||||
|
TOKEN=$(printf '%s' "$LOC" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||||
|
if [ -n "$TOKEN" ]; then
|
||||||
|
ok "POST checkout -> 303 straight to payment"
|
||||||
|
else
|
||||||
|
bad "checkout redirect" "Location was: $LOC"
|
||||||
|
fi
|
||||||
|
if grep -q '"country":"NL"' "$ORDERS" && grep -q '"total_minor":57830' "$ORDERS"; then
|
||||||
|
ok "order stored: NL total is €578.30 (green €563.30 + €15 shipping)"
|
||||||
|
else
|
||||||
|
bad "order storage" "expected NL total_minor 57830 in $ORDERS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The order page: awaiting payment, pay link, reference, self-refreshing,
|
||||||
|
# never indexed, never cached.
|
||||||
|
ORDER_HTML=$(curl -s "$BASE/order/$TOKEN")
|
||||||
|
printf '%s' "$ORDER_HTML" > "$WORK/order.html"
|
||||||
|
for probe in 'awaiting payment' 'Resume payment' 'CC-' 'http-equiv="refresh"' '€578.30'; do
|
||||||
|
if grep -qF -- "$probe" "$WORK/order.html"; then
|
||||||
|
ok "order page has $probe"
|
||||||
|
else
|
||||||
|
bad "order page" "missing: $probe"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
header_has "/order/$TOKEN" 'x-robots-tag: *noindex' "order page is noindex"
|
||||||
|
header_has "/order/$TOKEN" 'cache-control: *no-store' "order page is never cached"
|
||||||
|
|
||||||
|
# Unknown and malformed tokens are the same 404.
|
||||||
|
status /order/00000000000000000000000000000000 404
|
||||||
|
status /order/not-a-token 404
|
||||||
|
status /order/deadbeef 404
|
||||||
|
|
||||||
|
# A non-EU order: ex-VAT goods, world shipping, and the indicative national
|
||||||
|
# currency line sourced from the build-time ECB rates.
|
||||||
|
LOC_CA=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA' "$BASE/shop/fp6-pmos")
|
||||||
|
TOKEN_CA=$(printf '%s' "$LOC_CA" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||||
|
if [ -n "$TOKEN_CA" ]; then
|
||||||
|
CA_HTML=$(curl -s "$BASE/order/$TOKEN_CA")
|
||||||
|
# €465.54 goods (green net) + €55 world shipping = €520.54
|
||||||
|
if printf '%s' "$CA_HTML" | grep -qF '€520.54'; then
|
||||||
|
ok "export order total is ex-VAT + world shipping"
|
||||||
|
else
|
||||||
|
bad "export order total" "€520.54 not on the page"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CA_HTML" | grep -qF 'Zero-rated export'; then
|
||||||
|
ok "export order states the VAT treatment"
|
||||||
|
else
|
||||||
|
bad "export VAT copy" "missing zero-rated export note"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CA_HTML" | grep -qE '≈ CA\$[0-9]+'; then
|
||||||
|
ok "export order shows the indicative CAD amount"
|
||||||
|
else
|
||||||
|
# Rates are optional by design; their absence must not fail the file
|
||||||
|
# check, but in this repo rates.json is committed so it must appear.
|
||||||
|
bad "indicative currency" "no ≈ CA\$ line on the CA order page"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CA_HTML" | grep -qF 'indicative'; then
|
||||||
|
ok "conversion is labelled indicative"
|
||||||
|
else
|
||||||
|
bad "indicative label" "the conversion is not labelled indicative"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bad "CA checkout" "no token from Location: $LOC_CA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A two-unit white export order: unit €665, line €1330, net from the LINE
|
||||||
|
# total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
|
||||||
|
LOC_W=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
|
||||||
|
-d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CA&color=white&quantity=2' \
|
||||||
|
"$BASE/shop/fp6-pmos")
|
||||||
|
TOKEN_W=$(printf '%s' "$LOC_W" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||||
|
if [ -n "$TOKEN_W" ]; then
|
||||||
|
W_HTML=$(curl -s "$BASE/order/$TOKEN_W")
|
||||||
|
if printf '%s' "$W_HTML" | grep -qF '€1137.45'; then
|
||||||
|
ok "white ×2 export total nets the line, not the unit"
|
||||||
|
else
|
||||||
|
bad "variant qty total" "€1137.45 not on the page"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$W_HTML" | grep -qF 'Device × 2'; then
|
||||||
|
ok "order page shows the quantity"
|
||||||
|
else
|
||||||
|
bad "order quantity display" "no 'Device × 2'"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$W_HTML" | grep -qF 'White'; then
|
||||||
|
ok "order page names the colour"
|
||||||
|
else
|
||||||
|
bad "order colour display" "colour label missing"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bad "white checkout" "no token from Location: $LOC_W"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A colour we never listed must not buy anything, whatever the form claims.
|
||||||
|
status /shop/fp6-pmos 422 POST "$GOOD&color=mauve"
|
||||||
|
status /shop/fp6-pmos 422 POST "$GOOD&quantity=100"
|
||||||
|
status /shop/fp6-pmos 422 POST "$GOOD&quantity=0"
|
||||||
|
# Quantity is a free input with a technical ceiling, not a dropdown - a
|
||||||
|
# nine-unit order is business, not fraud.
|
||||||
|
LOC_9=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
|
||||||
|
-d "$GOOD&quantity=9" "$BASE/shop/fp6-pmos")
|
||||||
|
if printf '%s' "$LOC_9" | grep -qE '/order/[0-9a-f]{32}$'; then
|
||||||
|
ok "a nine-unit order goes through"
|
||||||
|
else
|
||||||
|
bad "bulk order" "quantity=9 did not create an order: $LOC_9"
|
||||||
|
fi
|
||||||
|
body_has /shop/fp6-pmos 'type="number"' "quantity is a number input, not a dropdown"
|
||||||
|
body_has /shop/fp6-pmos 'max="99"' "quantity input carries the technical ceiling"
|
||||||
|
|
||||||
|
# No invoice exists before the money does — awaiting orders answer 404.
|
||||||
|
status "/order/$TOKEN/invoice.md" 404
|
||||||
|
status /order/00000000000000000000000000000000/invoice.md 404
|
||||||
|
|
||||||
|
# The payment lands: create the fake rail's paid marker, then the reconciler
|
||||||
|
# (1 s cadence in fake mode) must flip the order within a few seconds.
|
||||||
|
touch "$ORDERS.fake-paid"
|
||||||
|
# The paid state shows the confirmation notice, deliberately WITHOUT a second
|
||||||
|
# "paid" badge — so the success marker is the notice text.
|
||||||
|
i=0
|
||||||
|
until curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; do
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ "$i" -gt 40 ]; then break; fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; then
|
||||||
|
ok "order confirms after payment (arrival poll or reconciler)"
|
||||||
|
else
|
||||||
|
bad "reconciler" "order still not confirmed 10s after the marker appeared"
|
||||||
|
fi
|
||||||
|
n_badges=$(curl -s "$BASE/order/$TOKEN" | grep -c 'badge--active' || true)
|
||||||
|
if [ "$n_badges" = 0 ]; then
|
||||||
|
ok "no duplicate paid badge next to the confirmation"
|
||||||
|
else
|
||||||
|
bad "badge dedupe" "found $n_badges active badges on the paid page"
|
||||||
|
fi
|
||||||
|
if curl -s "$BASE/order/$TOKEN" | grep -q 'http-equiv="refresh"'; then
|
||||||
|
bad "paid page refresh" "a settled order page still self-refreshes"
|
||||||
|
else
|
||||||
|
ok "paid order page stops self-refreshing"
|
||||||
|
fi
|
||||||
|
if grep -q '"type":"status"' "$ORDERS" && grep -q '"status":"paid"' "$ORDERS"; then
|
||||||
|
ok "paid transition is an appended event, not a rewrite"
|
||||||
|
else
|
||||||
|
bad "order event log" "no status event found in $ORDERS"
|
||||||
|
fi
|
||||||
|
# The paid event records HOW it was paid — card money stays reversible for
|
||||||
|
# months, so the ledger must show which orders carry that tail.
|
||||||
|
if grep -q '"via":"fake"' "$ORDERS"; then
|
||||||
|
ok "paid event records the payment method"
|
||||||
|
else
|
||||||
|
bad "payment method" "no via field on the paid event"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== the signed invoice =="
|
||||||
|
# Paid orders download a clearsigned markdown invoice: sequential number,
|
||||||
|
# registered identity, amounts — and a signature that verifies offline.
|
||||||
|
curl -s -D "$WORK/inv-headers" "$BASE/order/$TOKEN/invoice.md" > "$WORK/invoice.md"
|
||||||
|
for probe in 'BEGIN PGP SIGNED MESSAGE' '# Invoice ' 'Customer number: ' \
|
||||||
|
'Chico Mendesring 256' 'KVK 78437059' \
|
||||||
|
'NL003329281B38' 'CC-' 'VAT 21% (NL)' '€578.30'; do
|
||||||
|
if grep -qF -- "$probe" "$WORK/invoice.md"; then
|
||||||
|
ok "invoice has $probe"
|
||||||
|
else
|
||||||
|
bad "invoice content" "missing: $probe"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if grep -qi 'content-disposition: *attachment' "$WORK/inv-headers"; then
|
||||||
|
ok "invoice downloads as an attachment"
|
||||||
|
else
|
||||||
|
bad "invoice headers" "no attachment disposition"
|
||||||
|
fi
|
||||||
|
if gpg --verify "$WORK/invoice.md" >/dev/null 2>&1; then
|
||||||
|
ok "invoice signature verifies with gpg"
|
||||||
|
else
|
||||||
|
bad "invoice signature" "gpg --verify failed"
|
||||||
|
fi
|
||||||
|
# Four orders were placed before the marker (two of them by the same email);
|
||||||
|
# the arrival poll paid one instantly, the reconciler sweeps the rest on its
|
||||||
|
# 1 s cadence — wait for all four invoices before judging the numbering.
|
||||||
|
i=0
|
||||||
|
until [ "$(grep -c '"type":"invoice"' "$ORDERS" || true)" -ge 4 ]; do
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ "$i" -gt 40 ]; then break; fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
|
||||||
|
# Per-customer series, continuing the pre-shop administration: numbers are
|
||||||
|
# <customer-uuid>-<seq>, unique overall, and orders that share an email share
|
||||||
|
# a series with distinct sequence numbers.
|
||||||
|
n_inv=$(grep -c '"type":"invoice"' "$ORDERS" || true)
|
||||||
|
n_uniq=$(grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l)
|
||||||
|
if [ "$n_inv" -gt 0 ] && [ "$n_inv" = "$n_uniq" ]; then
|
||||||
|
ok "invoice numbers are unique ($n_inv issued)"
|
||||||
|
else
|
||||||
|
bad "invoice numbering" "$n_inv events, $n_uniq unique numbers"
|
||||||
|
fi
|
||||||
|
if grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | grep -qE '"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+"$'; then
|
||||||
|
ok "invoice numbers are customer-uuid series"
|
||||||
|
else
|
||||||
|
bad "invoice format" "no <uuid v4>-<seq> shaped number in the ledger"
|
||||||
|
fi
|
||||||
|
# The GOOD email placed several paid orders in this run — all of them must sit
|
||||||
|
# in ONE customer series (same uuid), with as many distinct sequence numbers.
|
||||||
|
n_customers=$(grep -o '"customer":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l)
|
||||||
|
n_orders_series=$(grep -c '"type":"invoice"' "$ORDERS")
|
||||||
|
if [ "$n_customers" -lt "$n_orders_series" ]; then
|
||||||
|
ok "repeat customer shares one series ($n_customers customers, $n_orders_series invoices)"
|
||||||
|
else
|
||||||
|
bad "customer series" "every invoice got its own customer uuid — series not shared"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "== checkout (coming soon) =="
|
||||||
|
# A perfectly valid order must be refused while the shop is closed: after
|
||||||
|
# validation (so the field checks below still exercise the parser) and before
|
||||||
|
# any rail or ledger is touched.
|
||||||
|
status /shop/fp6-pmos 409 POST "$GOOD"
|
||||||
|
if [ -s "$ORDERS" ]; then
|
||||||
|
bad "coming-soon ledger" "a refused order still wrote to $ORDERS"
|
||||||
|
else
|
||||||
|
ok "refused order writes nothing to the ledger"
|
||||||
|
fi
|
||||||
|
skip "checkout, order-lifecycle and invoice suites" "shop is coming-soon; they re-arm when the status flips to available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== checkout validation =="
|
||||||
|
status /shop/fp6-pmos 422 POST 'name=Ada&street=x&postal=1&city=y&country=NL' # no email
|
||||||
|
status /shop/fp6-pmos 422 POST 'email=nonsense&'"$GOOD" # bad email (dup field keeps first)
|
||||||
|
status /shop/fp6-pmos 422 POST 'email=a%40b.example&country=NL' # missing address
|
||||||
|
status /shop/fp6-pmos 422 POST "$GOOD&website=spam" # honeypot
|
||||||
|
status /shop/nope 404 POST "$GOOD" # unknown product
|
||||||
|
status /projects 405 POST 'x=1' # not a form target
|
||||||
|
|
||||||
|
# The re-rendered form only exists when the shop is open; while coming-soon a
|
||||||
|
# rejection answers with the coming-soon page instead.
|
||||||
|
if [ "$SHOP_OPEN" = 1 ]; then
|
||||||
|
# A rejected submission must come back with the values still in it — losing a
|
||||||
|
# filled-in form is how a sale gets abandoned.
|
||||||
|
curl -s -X POST -d 'email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD' \
|
||||||
|
"$BASE/shop/fp6-pmos" > "$WORK/rejected.html"
|
||||||
|
for probe in 'value="bad"' 'value="NLD"' 'value="Ada"' 'value="Main 1"' 'value="Delft"'; do
|
||||||
|
if grep -qF -- "$probe" "$WORK/rejected.html"; then
|
||||||
|
ok "rejected form preserves $probe"
|
||||||
|
else
|
||||||
|
bad "rejected form field" "lost: $probe"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if grep -qF 'field__error' "$WORK/rejected.html"; then
|
||||||
|
ok "rejected form shows a field error"
|
||||||
|
else
|
||||||
|
bad "rejected form error" "no .field__error in the response"
|
||||||
|
fi
|
||||||
|
# The honeypot message must not name the trap, or it teaches the next bot.
|
||||||
|
# Only the ERROR NOTICE is inspected: the re-rendered form legitimately
|
||||||
|
# contains the name="website" field itself — that IS the trap, re-armed.
|
||||||
|
curl -s -X POST -d "$GOOD&website=x" "$BASE/shop/fp6-pmos" > "$WORK/pot.html"
|
||||||
|
notice=$(grep -o 'notice--error">[^<]*' "$WORK/pot.html" || true)
|
||||||
|
if [ -z "$notice" ]; then
|
||||||
|
bad "honeypot rejection" "no error notice rendered"
|
||||||
|
elif printf '%s' "$notice" | grep -qiE 'honeypot|website|hidden|trap'; then
|
||||||
|
bad "honeypot disclosure" "the error notice names the trap: $notice"
|
||||||
|
else
|
||||||
|
ok "honeypot failure does not name the trap"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== abuse =="
|
||||||
|
status /shop/fp6-pmos 413 POST "email=a%40b.example&name=$(head -c 20000 /dev/zero | tr '\0' 'x')&street=x&postal=1&city=y&country=NL"
|
||||||
|
if curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' \
|
||||||
|
-d '{}' "$BASE/shop/fp6-pmos" | grep -q 415; then
|
||||||
|
ok "POST with a JSON content-type -> 415"
|
||||||
|
else
|
||||||
|
bad "content-type check" "expected 415"
|
||||||
|
fi
|
||||||
|
# HEAD must not be a 500 or a body — some crawlers use it exclusively.
|
||||||
|
status / 200 HEAD
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$skipped" -gt 0 ]; then
|
||||||
|
echo "e2e: $pass passed, $fail failed, $skipped skipped"
|
||||||
|
else
|
||||||
|
echo "e2e: $pass passed, $fail failed"
|
||||||
|
fi
|
||||||
|
[ "$fail" -eq 0 ] || exit 1
|
||||||
166
tools/fetch-media.sh
Executable file
166
tools/fetch-media.sh
Executable file
|
|
@ -0,0 +1,166 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Mirror the media referenced by content/posts.json, and rewrite the entries to
|
||||||
|
# point at our own copies.
|
||||||
|
#
|
||||||
|
# Run AFTER tools/fetch-posts.sh, which records the original URLs.
|
||||||
|
#
|
||||||
|
# WHY MIRROR rather than embed from the source:
|
||||||
|
#
|
||||||
|
# * Privacy. The privacy notice states that everything the browser loads comes
|
||||||
|
# from catcrafts.net, and it should stay true. Embedding directly would send
|
||||||
|
# every visitor's IP address to whichever instance hosts the file — an odd
|
||||||
|
# thing to do on a site selling a privacy-focused phone.
|
||||||
|
# * Durability. These posts ARE their media: the screen recording of VoLTE
|
||||||
|
# working is the content. If the source instance deletes it or disappears,
|
||||||
|
# a direct embed becomes a broken box and the post loses its point.
|
||||||
|
# * Cost. One download per file, ever, instead of one per visitor. Kinder to
|
||||||
|
# small instances than hotlinking them.
|
||||||
|
#
|
||||||
|
# Files are content-addressed (sha256 of the bytes), so a file already present is
|
||||||
|
# never downloaded again and a changed file gets a new name — which makes the
|
||||||
|
# long cache lifetime Caddy sets honest.
|
||||||
|
#
|
||||||
|
# usage: tools/fetch-media.sh [media-dir] (default: media/)
|
||||||
|
#
|
||||||
|
# On any single download failure the entry keeps its original URL and the script
|
||||||
|
# carries on, so one dead file does not cost the whole page. Exits non-zero only
|
||||||
|
# if it cannot do its job at all.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
MEDIA_DIR="${1:-media}"
|
||||||
|
POSTS="content/posts.json"
|
||||||
|
MAX_BYTES=$((64 * 1024 * 1024))
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo "fetch-media: jq not found" >&2; exit 1; }
|
||||||
|
[ -f "$POSTS" ] || { echo "fetch-media: $POSTS not found — run fetch-posts.sh first" >&2; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$MEDIA_DIR"
|
||||||
|
|
||||||
|
# ffprobe gives real pixel dimensions, which become width/height attributes.
|
||||||
|
# Without them the browser cannot reserve space and the text below jumps as each
|
||||||
|
# image arrives; with them the layout is stable on first paint. Optional — the
|
||||||
|
# markup degrades to no dimensions rather than failing.
|
||||||
|
HAVE_FFPROBE=0
|
||||||
|
command -v ffprobe >/dev/null 2>&1 && HAVE_FFPROBE=1
|
||||||
|
|
||||||
|
MAP="$(mktemp)"
|
||||||
|
trap 'rm -f "$MAP"' EXIT
|
||||||
|
printf '[]' > "$MAP"
|
||||||
|
|
||||||
|
downloaded=0
|
||||||
|
reused=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
# Every distinct media URL across all posts, so a file shared by two posts is
|
||||||
|
# fetched once. Video posters are in here too: a poster left pointing at the
|
||||||
|
# source instance would leak a visitor IP on page load exactly like an embedded
|
||||||
|
# image would, and it is the frame shown before anyone presses play.
|
||||||
|
#
|
||||||
|
# Fed by a here-document rather than a pipe so the counters below survive — in
|
||||||
|
# `jq | while`, the loop runs in a subshell and every increment is discarded.
|
||||||
|
while IFS= read -r src; do
|
||||||
|
[ -n "$src" ] || continue
|
||||||
|
|
||||||
|
ext=$(printf '%s' "$src" | sed -E 's/.*\.([A-Za-z0-9]+)$/\1/' | tr 'A-Z' 'a-z')
|
||||||
|
case "$ext" in
|
||||||
|
mp4|webm|mov|webp|png|jpg|jpeg|gif|avif) ;;
|
||||||
|
*) echo "fetch-media: skipping unexpected extension: $src" >&2; continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
# --max-filesize refuses an oversized body before writing it; the explicit
|
||||||
|
# size check afterwards covers servers that do not send Content-Length.
|
||||||
|
if ! curl -fsSL --max-time 120 --max-filesize "$MAX_BYTES" \
|
||||||
|
-A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \
|
||||||
|
"$src" -o "$tmp" 2>/dev/null; then
|
||||||
|
echo "fetch-media: download failed, keeping original URL: $src" >&2
|
||||||
|
rm -f "$tmp"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$(wc -c < "$tmp")" -gt "$MAX_BYTES" ]; then
|
||||||
|
echo "fetch-media: oversized, keeping original URL: $src" >&2
|
||||||
|
rm -f "$tmp"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
hash=$(sha256sum "$tmp" | cut -c1-16)
|
||||||
|
name="$hash.$ext"
|
||||||
|
dest="$MEDIA_DIR/$name"
|
||||||
|
|
||||||
|
if [ -f "$dest" ]; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
reused=$((reused + 1))
|
||||||
|
else
|
||||||
|
mv "$tmp" "$dest"
|
||||||
|
chmod 0644 "$dest"
|
||||||
|
downloaded=$((downloaded + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One query per dimension. Asking for both at once and splitting the CSV
|
||||||
|
# looked simpler but was wrong: for some files ffprobe appends an empty
|
||||||
|
# field, so `width,height` came back as "854x480x" and splitting on `x` gave
|
||||||
|
# a height of "480x" — which the digit guard below then threw away, silently
|
||||||
|
# costing the dimensions of exactly the videos that had the extra field.
|
||||||
|
# `nk=1` prints the bare value, so there is nothing to split.
|
||||||
|
w=0; h=0
|
||||||
|
if [ "$HAVE_FFPROBE" = 1 ]; then
|
||||||
|
pw=$(ffprobe -v error -select_streams v:0 -show_entries stream=width \
|
||||||
|
-of default=nw=1:nk=1 "$dest" 2>/dev/null | head -n1 || true)
|
||||||
|
ph=$(ffprobe -v error -select_streams v:0 -show_entries stream=height \
|
||||||
|
-of default=nw=1:nk=1 "$dest" 2>/dev/null | head -n1 || true)
|
||||||
|
case "$pw" in ''|*[!0-9]*) pw=0 ;; esac
|
||||||
|
case "$ph" in ''|*[!0-9]*) ph=0 ;; esac
|
||||||
|
# Both or neither: a lone dimension is worse than none, because the
|
||||||
|
# browser derives the missing one from it and gets the aspect wrong.
|
||||||
|
if [ "$pw" -gt 0 ] && [ "$ph" -gt 0 ]; then w=$pw; h=$ph; fi
|
||||||
|
if [ "$w" = 0 ]; then
|
||||||
|
echo "fetch-media: no dimensions for $name; layout will shift on load" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq --arg src "$src" --arg path "/media/$name" \
|
||||||
|
--argjson w "${w:-0}" --argjson h "${h:-0}" \
|
||||||
|
'. + [{src: $src, path: $path, w: $w, h: $h}]' "$MAP" > "$MAP.new" \
|
||||||
|
&& mv "$MAP.new" "$MAP"
|
||||||
|
done <<EOF
|
||||||
|
$(jq -r '[.[].media[]? | .src, (.poster // empty)] | map(select(. != "")) | unique[]' "$POSTS")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "fetch-media: $downloaded new, $reused already present, $failed failed"
|
||||||
|
|
||||||
|
# Rewrite each media entry to the local path. An entry with no mapping (download
|
||||||
|
# failed) keeps its original src, so the page still shows something rather than
|
||||||
|
# silently dropping the post's whole point.
|
||||||
|
TMP_POSTS="$(mktemp)"
|
||||||
|
if jq --slurpfile map "$MAP" '
|
||||||
|
($map[0] | map({key: .src, value: .}) | from_entries) as $m
|
||||||
|
| map(.media = ((.media // []) | map(
|
||||||
|
. as $item
|
||||||
|
| ($m[$item.src] // null) as $hit
|
||||||
|
| (if $hit == null then $item
|
||||||
|
else $item + { src: $hit.path, w: $hit.w, h: $hit.h }
|
||||||
|
end)
|
||||||
|
# The poster gets its path rewritten but NOT its dimensions: w/h describe
|
||||||
|
# the video, and a poster is a differently-sized still of it. Feeding the
|
||||||
|
# poster'\''s size to the <video> element would set the wrong aspect ratio.
|
||||||
|
| if (.poster // "") == "" then .
|
||||||
|
else . + { poster: (($m[.poster].path) // .poster) }
|
||||||
|
end)))
|
||||||
|
' "$POSTS" > "$TMP_POSTS" 2>/dev/null; then
|
||||||
|
mv "$TMP_POSTS" "$POSTS"
|
||||||
|
else
|
||||||
|
rm -f "$TMP_POSTS"
|
||||||
|
echo "fetch-media: could not rewrite $POSTS, leaving it unchanged" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
total=$(jq '[.[].media[]? | .src, (.poster // empty) | select(. != "")] | length' "$POSTS")
|
||||||
|
local_count=$(jq '[.[].media[]? | .src, (.poster // empty)
|
||||||
|
| select(startswith("/media/"))] | length' "$POSTS")
|
||||||
|
echo "fetch-media: $local_count of $total media entries served locally ($(du -sh "$MEDIA_DIR" | cut -f1) in $MEDIA_DIR)"
|
||||||
|
if [ "$local_count" -ne "$total" ]; then
|
||||||
|
echo "fetch-media: $((total - local_count)) still point at their source — see the failures above" >&2
|
||||||
|
fi
|
||||||
219
tools/fetch-posts.sh
Executable file
219
tools/fetch-posts.sh
Executable file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Fetch selected fediverse posts and write content/posts.json.
|
||||||
|
#
|
||||||
|
# Runs at BUILD time, not run time. The site does not crawl anything, does not
|
||||||
|
# proxy, and does not mirror comments — each card links out to the thread on
|
||||||
|
# whatever instance it lives on, which is where the discussion belongs. That
|
||||||
|
# means no sync service and no runtime dependency on any instance being up.
|
||||||
|
#
|
||||||
|
# WHICH POSTS: the Lemmy user API returns everything the account has posted,
|
||||||
|
# across every community. That is not what belongs on a site about this work, so
|
||||||
|
# the result is filtered against the community allowlist in
|
||||||
|
# content/posts-sources.json. Joining a new community does not silently publish
|
||||||
|
# to the site — it has to be added there first.
|
||||||
|
#
|
||||||
|
# The output is a flat array of exactly the fields Catcrafts.Shared:Model reads.
|
||||||
|
# Doing the transformation here rather than in C++ keeps the parser small and
|
||||||
|
# makes an upstream API change a one-file fix in shell.
|
||||||
|
#
|
||||||
|
# usage: tools/fetch-posts.sh [config-file]
|
||||||
|
#
|
||||||
|
# On any failure the existing content/posts.json is left untouched and the script
|
||||||
|
# exits 0. A build must not fail because an instance was down, and stale posts
|
||||||
|
# are strictly better than an empty page.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG="${1:-content/posts-sources.json}"
|
||||||
|
OUT="content/posts.json"
|
||||||
|
LIMIT=50
|
||||||
|
EXCERPT_CHARS=280
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo "fetch-posts: jq not found, keeping existing $OUT" >&2; exit 0; }
|
||||||
|
[ -f "$CONFIG" ] || { echo "fetch-posts: $CONFIG not found, keeping existing $OUT" >&2; exit 0; }
|
||||||
|
|
||||||
|
USER_NAME=$(jq -r '.username // empty' "$CONFIG")
|
||||||
|
INSTANCE=$(jq -r '.instance // empty' "$CONFIG")
|
||||||
|
[ -n "$USER_NAME" ] && [ -n "$INSTANCE" ] || {
|
||||||
|
echo "fetch-posts: $CONFIG needs .username and .instance, keeping existing $OUT" >&2; exit 0; }
|
||||||
|
|
||||||
|
# An empty allowlist would silently publish everything, which is the opposite of
|
||||||
|
# what this file is for — treat it as a configuration error, not as "allow all".
|
||||||
|
COMMUNITY_COUNT=$(jq '.communities | length' "$CONFIG")
|
||||||
|
[ "$COMMUNITY_COUNT" -gt 0 ] || {
|
||||||
|
echo "fetch-posts: .communities is empty — refusing to publish every community" >&2
|
||||||
|
echo "fetch-posts: keeping existing $OUT" >&2; exit 0; }
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
RAW="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP" "$RAW"' EXIT
|
||||||
|
|
||||||
|
URL="$INSTANCE/api/v3/user?username=$USER_NAME&sort=New&limit=$LIMIT"
|
||||||
|
|
||||||
|
# Identify ourselves: an unattributed scraper on a small instance is rude and
|
||||||
|
# more likely to get blocked.
|
||||||
|
if ! curl -fsS --max-time 25 \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \
|
||||||
|
"$URL" -o "$RAW"; then
|
||||||
|
echo "fetch-posts: request failed, keeping existing $OUT" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Map PostView -> the flat shape the C++ loader reads.
|
||||||
|
#
|
||||||
|
# community : assembled as name@instance from the community's actor_id, which
|
||||||
|
# is what the allowlist matches on. A post made INTO
|
||||||
|
# linuxphones@lemmy.ca has that as its community even though the
|
||||||
|
# account lives elsewhere — which is exactly the distinction that
|
||||||
|
# matters here.
|
||||||
|
# permalink : post.ap_id, the canonical federated URL. Correct even when the
|
||||||
|
# post lives on another instance, which post.id is not. This is
|
||||||
|
# also what the "discuss on the fediverse" link uses, so the reader
|
||||||
|
# lands on the real thread rather than a local mirror of it.
|
||||||
|
# media : the post's MAIN media only — `post.url` — not images embedded in
|
||||||
|
# the body. The API distinguishes them and so should we: `url` is
|
||||||
|
# what the post is about (the screen recording of the work), while
|
||||||
|
# body images are illustrations inside the prose, usually
|
||||||
|
# screenshots of comments. Pulling both in meant a card showing
|
||||||
|
# four files where one was the point.
|
||||||
|
# poster : post.thumbnail_url, the instance-generated still of that media.
|
||||||
|
# Used as a video poster so the player shows a frame instead of a
|
||||||
|
# black box before playing.
|
||||||
|
#
|
||||||
|
# Both are ORIGINAL urls here; tools/fetch-media.sh mirrors them
|
||||||
|
# and rewrites to local paths, so nothing the browser loads is
|
||||||
|
# third-party.
|
||||||
|
# excerpt : body flattened to one line and truncated. Markdown is NOT
|
||||||
|
# rendered — the site has no markdown pipeline by design, so any
|
||||||
|
# surviving syntax would show as literal characters. Strip the
|
||||||
|
# common inline markers and let the rest be plain text.
|
||||||
|
# deleted / removed posts are dropped rather than rendered as empty cards.
|
||||||
|
if ! jq --argjson n "$EXCERPT_CHARS" \
|
||||||
|
--slurpfile cfg "$CONFIG" '
|
||||||
|
($cfg[0].communities | map(ascii_downcase)) as $allow
|
||||||
|
| [ .posts[]
|
||||||
|
| select((.post.deleted // false) == false)
|
||||||
|
| select((.post.removed // false) == false)
|
||||||
|
| . as $p
|
||||||
|
| ((.community.name // "") + "@" +
|
||||||
|
((.community.actor_id // "") | sub("^https?://"; "") | sub("/c/.*$"; ""))) as $comm
|
||||||
|
| select(($comm | ascii_downcase) as $c | $allow | index($c))
|
||||||
|
| {
|
||||||
|
title: ($p.post.name // ""),
|
||||||
|
permalink: ($p.post.ap_id // ""),
|
||||||
|
# A link post whose target IS an image or video is a media post, not a
|
||||||
|
# link post: the file is captured in `media` and embedded, so keeping
|
||||||
|
# it here too would render the raw URL as text right above the thing
|
||||||
|
# it points at.
|
||||||
|
url: (($p.post.url // "")
|
||||||
|
| if test("\\.(?:mp4|webm|mov|webp|png|jpe?g|gif|avif)$") then "" else . end),
|
||||||
|
community: $comm,
|
||||||
|
published: ($p.post.published // ""),
|
||||||
|
excerpt: (($p.post.body // "")
|
||||||
|
| gsub("\r"; "")
|
||||||
|
| gsub("\n+"; " ")
|
||||||
|
| gsub("!?\\[(?<t>[^\\]]*)\\]\\([^)]*\\)"; "\(.t)")
|
||||||
|
| gsub("[*_`>#]"; "")
|
||||||
|
# Strip every bare URL, not just media ones. A raw link
|
||||||
|
# in a 280-character preview is noise the reader cannot
|
||||||
|
# use, and the card already links to the thread — where
|
||||||
|
# the link is clickable in its original context.
|
||||||
|
| gsub("https?://[^ )\\]]+"; "")
|
||||||
|
| gsub(" +"; " ")
|
||||||
|
| ltrimstr(" ") | rtrimstr(" ")
|
||||||
|
| if (. | length) > $n then (.[0:$n] | sub(" [^ ]*$"; "")) + "…" else . end),
|
||||||
|
media: ([ ($p.post.url // "")
|
||||||
|
| select(test("\\.(?:mp4|webm|mov|webp|png|jpe?g|gif|avif)$"))
|
||||||
|
| (if test("\\.(mp4|webm|mov)$") then "video" else "image" end) as $kind
|
||||||
|
| { src: .,
|
||||||
|
kind: $kind,
|
||||||
|
# Videos only. For an image post thumbnail_url is a
|
||||||
|
# scaled copy of the image itself, and <img> has no
|
||||||
|
# poster attribute — carrying it would mirror a
|
||||||
|
# second file to render nothing.
|
||||||
|
poster: (if $kind == "video"
|
||||||
|
then (($p.post.thumbnail_url // "")
|
||||||
|
| select(test("\\.(?:webp|png|jpe?g|gif|avif)$")) // "")
|
||||||
|
else "" end) } ]),
|
||||||
|
score: ($p.counts.score // 0),
|
||||||
|
comments: ($p.counts.comments // 0)
|
||||||
|
}
|
||||||
|
]' "$RAW" > "$TMP" 2>/dev/null; then
|
||||||
|
echo "fetch-posts: response did not match the expected shape, keeping existing $OUT" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Refuse to replace good content with an empty list. Zero matches usually means
|
||||||
|
# the allowlist and the account have drifted apart, or the API shape changed —
|
||||||
|
# either way, silently emptying the posts page on the next deploy is the wrong
|
||||||
|
# response.
|
||||||
|
COUNT="$(jq 'length' "$TMP")"
|
||||||
|
if [ "$COUNT" -eq 0 ]; then
|
||||||
|
echo "fetch-posts: no posts matched the community allowlist, keeping existing $OUT" >&2
|
||||||
|
echo "fetch-posts: allowlist is $(jq -c '.communities' "$CONFIG")" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── point each link at the community's instance ─────────────────────────
|
||||||
|
#
|
||||||
|
# `post.ap_id` is the ActivityPub canonical id, and for a post created from this
|
||||||
|
# account it is on the account's own instance — so linking it sends readers
|
||||||
|
# there. That is the wrong destination twice over: the community lives somewhere
|
||||||
|
# else, and the account is not what the site should be advertising.
|
||||||
|
#
|
||||||
|
# The community's instance has its own federated copy of the thread at a
|
||||||
|
# different local id. `resolve_object` is how to find it: hand the instance the
|
||||||
|
# ap_id and it answers with its local view.
|
||||||
|
#
|
||||||
|
# Per post, one request, at build time. Failure is not fatal — the entry keeps
|
||||||
|
# its ap_id, which still reaches a readable copy of the thread.
|
||||||
|
resolved=0
|
||||||
|
kept=0
|
||||||
|
LINKED="$(mktemp)"
|
||||||
|
printf '[]' > "$LINKED"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
while IFS="$(printf '\t')" read -r ap comm; do
|
||||||
|
[ -n "$ap" ] || continue
|
||||||
|
host=${comm#*@}
|
||||||
|
if [ -z "$host" ] || [ "$host" = "$comm" ]; then
|
||||||
|
kept=$((kept + 1)); continue
|
||||||
|
fi
|
||||||
|
local_id=$(curl -fsS --max-time 15 \
|
||||||
|
-A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \
|
||||||
|
"https://$host/api/v3/resolve_object?q=$ap" 2>/dev/null \
|
||||||
|
| jq -r '.post.post.id // empty' 2>/dev/null || true)
|
||||||
|
case "$local_id" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "fetch-posts: could not resolve $ap on $host, keeping the ap_id" >&2
|
||||||
|
kept=$((kept + 1))
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
jq --arg ap "$ap" --arg url "https://$host/post/$local_id" \
|
||||||
|
'. + [{ap: $ap, url: $url}]' "$LINKED" > "$LINKED.new" \
|
||||||
|
&& mv "$LINKED.new" "$LINKED"
|
||||||
|
resolved=$((resolved + 1))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$(jq -r '.[] | [.permalink, .community] | @tsv' "$TMP")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
REWRITTEN="$(mktemp)"
|
||||||
|
if jq --slurpfile linked "$LINKED" '
|
||||||
|
($linked[0] | map({key: .ap, value: .url}) | from_entries) as $m
|
||||||
|
| map(. + { permalink: ($m[.permalink] // .permalink) })' "$TMP" > "$REWRITTEN" 2>/dev/null; then
|
||||||
|
mv "$REWRITTEN" "$TMP"
|
||||||
|
else
|
||||||
|
rm -f "$REWRITTEN"
|
||||||
|
echo "fetch-posts: link rewrite failed, keeping ap_ids" >&2
|
||||||
|
fi
|
||||||
|
rm -f "$LINKED"
|
||||||
|
|
||||||
|
mkdir -p content
|
||||||
|
mv "$TMP" "$OUT"
|
||||||
|
trap - EXIT
|
||||||
|
rm -f "$RAW"
|
||||||
|
echo "fetch-posts: wrote $COUNT posts to $OUT (from $COMMUNITY_COUNT allowed communities)"
|
||||||
|
echo "fetch-posts: $resolved links point at the community instance, $kept fell back to the ap_id"
|
||||||
78
tools/fetch-rates.sh
Executable file
78
tools/fetch-rates.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Fetch the ECB euro reference rates and write content/rates.json.
|
||||||
|
#
|
||||||
|
# Feeds the indicative national-currency line on the order page ("≈ CA$920 ·
|
||||||
|
# ECB reference rate 2026-08-04"). Indicative is the contract: every charge is
|
||||||
|
# in euros, the buyer's bank sets the real conversion — so build-time daily
|
||||||
|
# reference rates are exactly the right freshness, and no rate service is ever
|
||||||
|
# called at page-view time (nothing third-party runs against visitors).
|
||||||
|
#
|
||||||
|
# Values are emitted as INTEGER micro-units of target currency per euro
|
||||||
|
# (1 EUR = 1.0834 USD -> 1083400), so the C++ side never parses a decimal and
|
||||||
|
# no float ever touches a money path.
|
||||||
|
#
|
||||||
|
# Like fetch-posts.sh: exits 0 on network failure, leaving any previous
|
||||||
|
# rates.json in place — a stale indicative rate labelled with its date beats a
|
||||||
|
# failed deploy.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
OUT="content/rates.json"
|
||||||
|
URL="https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
if ! curl -fsSL --max-time 30 \
|
||||||
|
-A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \
|
||||||
|
"$URL" -o "$TMP" 2>/dev/null; then
|
||||||
|
echo "fetch-rates: ECB unreachable; keeping existing $OUT" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The XML is a flat list: <Cube currency='USD' rate='1.1515'/> under one
|
||||||
|
# <Cube time='2026-08-04'>. The ECB emits single-quoted attributes today;
|
||||||
|
# normalising quotes first keeps this working if they ever switch to double.
|
||||||
|
tr "'" '"' < "$TMP" > "$TMP.n" && mv "$TMP.n" "$TMP"
|
||||||
|
|
||||||
|
DATE=$(grep -o 'time="[0-9-]*"' "$TMP" | head -n1 | cut -d'"' -f2)
|
||||||
|
if [ -z "$DATE" ]; then
|
||||||
|
echo "fetch-rates: unexpected ECB payload; keeping existing $OUT" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
RATES=$(grep -o 'currency="[A-Z]*" rate="[0-9.]*"' "$TMP" | awk -F'"' '
|
||||||
|
{
|
||||||
|
cur = $2; rate = $4
|
||||||
|
# decimal -> integer micros, without floats: split on the point and
|
||||||
|
# right-pad the fraction to exactly six digits.
|
||||||
|
n = split(rate, parts, ".")
|
||||||
|
intpart = parts[1]
|
||||||
|
frac = (n > 1) ? parts[2] : ""
|
||||||
|
frac = substr(frac "000000", 1, 6)
|
||||||
|
micro = intpart frac
|
||||||
|
# strip leading zeros (but keep at least one digit)
|
||||||
|
sub(/^0+/, "", micro); if (micro == "") micro = "0"
|
||||||
|
printf "%s\"%s\":%s", (out++ ? "," : ""), cur, micro
|
||||||
|
}')
|
||||||
|
|
||||||
|
if [ -z "$RATES" ]; then
|
||||||
|
echo "fetch-rates: no rates parsed; keeping existing $OUT" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"date":"%s","micro_per_eur":{%s}}\n' "$DATE" "$RATES" > "$OUT.new"
|
||||||
|
|
||||||
|
# Sanity: the file must parse and contain USD, or something upstream changed
|
||||||
|
# shape and the old file is the safer one.
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if ! jq -e '.micro_per_eur.USD > 500000' "$OUT.new" >/dev/null 2>&1; then
|
||||||
|
echo "fetch-rates: output failed sanity check; keeping existing $OUT" >&2
|
||||||
|
rm -f "$OUT.new"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$OUT.new" "$OUT"
|
||||||
|
count=$(grep -o ':' "$OUT" | wc -l)
|
||||||
|
echo "fetch-rates: wrote $OUT ($DATE, $((count - 1)) currencies)"
|
||||||
76
tools/fix-bundle-depth.sh
Executable file
76
tools/fix-bundle-depth.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Make the wasm bundle's index.html work when it is served at a URL deeper than
|
||||||
|
# "/", and verify it stayed that way.
|
||||||
|
#
|
||||||
|
# usage: tools/fix-bundle-depth.sh <bundle-dir>
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
#
|
||||||
|
# Crafter.Build generates an index.html whose boot scripts are relative
|
||||||
|
# (src="runtime.js?v=…"), and runtime.js in turn does fetch("variants.json"),
|
||||||
|
# fetch("files.json"), one fetch per VFS entry, and fetches the .wasm named by
|
||||||
|
# variants.json — all relative. Relative to the DOCUMENT, not to the module.
|
||||||
|
#
|
||||||
|
# That is correct when the document is "/". It is broken for every deeper path,
|
||||||
|
# and this site has several: /demos/raytracer, /shop/<slug>, /legal/<page>. A
|
||||||
|
# document at /demos/raytracer sends the browser to /demos/runtime.js, which does
|
||||||
|
# not exist, so Caddy's `try_files {path} /index.html` returns index.html — and
|
||||||
|
# the browser refuses to execute a module served as text/html. The visible result
|
||||||
|
# is four NS_ERROR_CORRUPTED_CONTENT errors and a dead page.
|
||||||
|
#
|
||||||
|
# The SSR path solves this itself (Views::RenderDocument emits <base href="/"> on
|
||||||
|
# any page that boots wasm). This script covers the OTHER path: the static shell
|
||||||
|
# Caddy serves directly when the backend is down, where there is no SSR to help.
|
||||||
|
#
|
||||||
|
# Two changes, both idempotent:
|
||||||
|
# 1. <base href="/"> in <head>, which fixes every relative fetch runtime.js
|
||||||
|
# makes, since a bare relative fetch() resolves against the document base.
|
||||||
|
# 2. Root the boot script srcs, which <base> already handles but which is worth
|
||||||
|
# doing anyway so the tags are correct even if the <base> is ever dropped.
|
||||||
|
#
|
||||||
|
# The durable fix belongs upstream: runtime.js should resolve its own assets
|
||||||
|
# against import.meta.url rather than the document. Then no bundle would care how
|
||||||
|
# deep the page is. Until then, this.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DIR="${1:-}"
|
||||||
|
[ -n "$DIR" ] || { echo "usage: tools/fix-bundle-depth.sh <bundle-dir>" >&2; exit 1; }
|
||||||
|
IDX="$DIR/index.html"
|
||||||
|
[ -f "$IDX" ] || { echo "fix-bundle-depth: $IDX not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
# 1. Root every relative script src. Anchored on `src="` immediately followed by
|
||||||
|
# something that is not / : and : covers http:, https: and any other scheme,
|
||||||
|
# / covers both already-rooted and protocol-relative //host.
|
||||||
|
sed -E 's|(<script[^>]*[[:space:]]src=")([^"/:][^"]*")|\1/\2|g' "$IDX" > "$TMP"
|
||||||
|
|
||||||
|
# 2. Insert <base href="/"> as the first thing in <head>, unless one is present.
|
||||||
|
# First, so it applies to everything after it — a <base> only governs the
|
||||||
|
# references that follow it.
|
||||||
|
if ! grep -qi '<base[[:space:]]' "$TMP"; then
|
||||||
|
sed -E '0,/<head>/s|<head>|<head>\n<base href="/">|' "$TMP" > "$TMP.b" \
|
||||||
|
&& mv "$TMP.b" "$TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$TMP" "$IDX"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
# Verify rather than assume. A silent no-op here would ship the broken page.
|
||||||
|
fail=0
|
||||||
|
if ! grep -qi '<base href="/">' "$IDX"; then
|
||||||
|
echo "fix-bundle-depth: FAILED to insert <base> into $IDX" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
rel=$(grep -oE '<script[^>]*[[:space:]]src="[^"/:][^"]*"' "$IDX" | wc -l)
|
||||||
|
if [ "$rel" -ne 0 ]; then
|
||||||
|
echo "fix-bundle-depth: $rel script src(s) are still relative in $IDX:" >&2
|
||||||
|
grep -oE '<script[^>]*[[:space:]]src="[^"/:][^"]*"' "$IDX" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
[ "$fail" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
n=$(grep -coE '<script[^>]*[[:space:]]src="/' "$IDX" || true)
|
||||||
|
echo "fix-bundle-depth: $IDX has <base href=\"/\"> and $n rooted script src(s)"
|
||||||
Loading…
Reference in a new issue