rewrite
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:18:37 +02:00
commit 934c94cb5c
50 changed files with 10464 additions and 758 deletions

204
tools/dev.sh Executable file
View 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
View 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 &mdash; €569.30' "colour selector prices black"
body_has /shop/fp6-pmos 'White &mdash; €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
View 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
View 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
View 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
View 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)"