This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
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