diff --git a/.claude/skills/wizwar-pulse/SKILL.md b/.claude/skills/wizwar-pulse/SKILL.md index 968b332..6cefdf6 100644 --- a/.claude/skills/wizwar-pulse/SKILL.md +++ b/.claude/skills/wizwar-pulse/SKILL.md @@ -69,7 +69,7 @@ action, or "nothing needs you." - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch requires it; a reboot there is maintenance, not an outage. - Caddy access logs live at /var/lib/caddy/access.log (self-rotating, - 10MiB × 30 since 2026-09-03); the systemd sandbox denies /var/log/caddy. + 10MiB × 30); the systemd sandbox denies /var/log/caddy. - Per-address limits (2026-09-03): 12 new rooms and 6 reports per address per hour, in packages/server/src/ratelimit.ts. A player who hits one sees "try again in an hour"; a pulse showing many refused diff --git a/deploy/clips-prep.mjs b/deploy/clips-prep.mjs index e8d3a60..56936e9 100755 --- a/deploy/clips-prep.mjs +++ b/deploy/clips-prep.mjs @@ -14,9 +14,10 @@ // Needs ffmpeg + ffprobe and the Playwright chromium cache (see // verify-scenes.mjs). Idempotent: run it again after re-recording. import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium } from "playwright-core"; +import { chromiumExe, ff } from "./lib/harness.mjs"; const dir = process.argv[2]; if (!dir || !existsSync(join(dir, "clips.json"))) { @@ -29,22 +30,6 @@ if (!dir || !existsSync(join(dir, "clips.json"))) { const VIEWPORT = { x: 18, y: 51, w: 700, h: 393 }; const CARD = { width: 1200, height: 630 }; -function chromiumExe() { - if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; - const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); - const builds = existsSync(cache) - ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() - : []; - for (const build of builds.reverse()) { - const exe = join(cache, build, "chrome-mac-arm64", - "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); - if (existsSync(exe)) return exe; - } - throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); -} - -const ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]); - function faststart(file) { const tmp = `${file}.tmp.mp4`; ff("-i", file, "-c", "copy", "-movflags", "+faststart", tmp); @@ -61,10 +46,11 @@ const esc = (s) => s.replace(/&/g, "&").replace(/
${esc(clip.blurb)}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8e0c0dd..6b29495 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,7 +31,7 @@ // {type:"error", message} import * as Sentry from "@sentry/node"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomBytes } from "node:crypto"; import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; @@ -65,7 +65,7 @@ import { abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; -import { appendFeedback, readFeedback, readClips, clipAssetPath } from "./store"; +import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store"; import { clipsIndexHtml, clipPageHtml } from "./clips"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; @@ -184,6 +184,15 @@ function ogPage(metas: string[]): string { /** Host and proto arrive from request headers — attacker-writable text * that must never reach an HTML attribute raw. */ +const noSuchClip = (res: ServerResponse) => + res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips"); + +/** The absolute origin a request came in on, as the proxy saw it. */ +function requestBase(req: IncomingMessage): string { + const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); + return safeBase(String(req.headers.host ?? `localhost:${port}`), proto); +} + function safeBase(rawHost: string, rawProto: string): string { const proto = /^https?$/.test(rawProto) ? rawProto : "https"; return `${proto}://${escapeHtml(rawHost)}`; @@ -312,16 +321,15 @@ const httpServer = createServer((req, res) => { // with Range support — Safari refuses an mp4 whose server can't // serve bytes 0-1 on demand. if (url === "/clips" || url === "/clips/") { - const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); - const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); + const base = requestBase(req); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipsIndexHtml(readClips(), base)); return; } - const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/); + const clipAsset = url.match(/^\/clips\/([^/]+\.(?:mp4|jpg))$/); if (clipAsset) { const path = clipAssetPath(clipAsset[1]!); - if (!path) { res.writeHead(404).end("no such clip"); return; } + if (!path) { noSuchClip(res); return; } const size = statSync(path).size; const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg"; const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? "")); @@ -349,17 +357,16 @@ const httpServer = createServer((req, res) => { createReadStream(path).pipe(res); return; } - const clipPage = url.match(/^\/clips\/([a-z0-9-]{1,60})$/); - if (clipPage) { + const clipPage = url.match(/^\/clips\/([^/]+)$/); + if (clipPage && CLIP_SLUG.test(clipPage[1]!)) { const clip = readClips().find((c) => c.name === clipPage[1]); if (clip) { - const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); - const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); + const base = requestBase(req); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipPageHtml(clip, base)); return; } - res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips"); + noSuchClip(res); return; } // Room invitations: a living room gets its recruiting card; a dead diff --git a/packages/server/src/ratelimit.ts b/packages/server/src/ratelimit.ts index 3e38e87..e04d773 100644 --- a/packages/server/src/ratelimit.ts +++ b/packages/server/src/ratelimit.ts @@ -31,6 +31,8 @@ export class SlidingLimit { } } +const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + /** The client's address as Caddy reports it. The proxy APPENDS the true * peer to X-Forwarded-For, so the last entry is the trustworthy one; a * client can write anything into the first. And the header is believed @@ -43,5 +45,3 @@ export function clientAddress(headers: Record