diff --git a/.dockerignore b/.dockerignore index 7343793..dfdec94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,19 @@ node_modules .env logs *.sqlite +*.sqlite-shm +*.sqlite-wal .DS_Store .cursor .idea + +# Dev/utility files not needed in production +bulk-test.ts +check-db.ts +quipslop.tsx +scripts/ +*.md +todo.md +CLAUDE.md +README.md +LICENSE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce3807a --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Required +OPENROUTER_API_KEY=your_openrouter_api_key_here + +# Admin panel authentication (required for admin features) +ADMIN_SECRET=your_secure_admin_passcode_here + +# Server +PORT=5109 +NODE_ENV=production + +# Database +DATABASE_PATH=quipslop.sqlite + +# Rate limiting (requests per minute) +WS_UPGRADE_LIMIT_PER_MIN=20 +HISTORY_LIMIT_PER_MIN=120 +ADMIN_LIMIT_PER_MIN=10 + +# WebSocket limits +MAX_WS_GLOBAL=100000 +MAX_WS_PER_IP=8 + +# History pagination +MAX_HISTORY_PAGE=100000 +MAX_HISTORY_LIMIT=50 +HISTORY_CACHE_TTL_MS=5000 +MAX_HISTORY_CACHE_KEYS=500 + +# Streaming (optional, for scripts/stream-browser.ts) +TWITCH_STREAM_KEY=your_twitch_stream_key_here +STREAM_TARGET_SIZE=1920x1080 diff --git a/.gitignore b/.gitignore index 66edbf5..e1df0be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ coverage # logs logs -_.log +*.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # dotenv environment variable files @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store *.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/Dockerfile b/Dockerfile index cf25b3c..7cf25de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,13 @@ RUN bun install --frozen-lockfile --production COPY . . ENV NODE_ENV=production -EXPOSE ${PORT:-5109} +EXPOSE 5109 + +# Run as non-root user +RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app +USER app + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 5109) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD ["bun", "server.ts"] diff --git a/admin.tsx b/admin.tsx index 423c1d2..6fbb757 100644 --- a/admin.tsx +++ b/admin.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./admin.css"; @@ -82,7 +82,50 @@ function App() { }; }, []); - const busy = useMemo(() => pending !== null, [pending]); + // Poll admin status every 10 seconds when authenticated + useEffect(() => { + if (mode !== "ready") return; + const interval = setInterval(() => { + requestAdminJson("/api/admin/status") + .then((data) => setSnapshot(data)) + .catch(() => {}); + }, 10_000); + return () => clearInterval(interval); + }, [mode]); + + // Modal escape key handler + useEffect(() => { + if (!isResetOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setIsResetOpen(false); + setResetText(""); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isResetOpen]); + + // Modal focus trap + const modalRef = useRef(null); + const handleModalKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !modalRef.current) return; + const focusable = modalRef.current.querySelectorAll( + 'button, input, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, []); + + const busy = pending !== null; async function onLogin(event: React.FormEvent) { event.preventDefault(); @@ -328,8 +371,8 @@ function App() { {isResetOpen && ( -
-
+
+

Reset all data?

This permanently deletes every saved round and resets scores. diff --git a/broadcast.ts b/broadcast.ts index 27883f1..1b30a08 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -560,6 +560,26 @@ function startCanvasCaptureSink() { const sink = params.get("sink"); if (!sink) return; + // Only allow WebSocket connections to localhost to prevent data exfiltration + try { + const sinkUrl = new URL(sink); + const isLocalhost = + sinkUrl.hostname === "localhost" || + sinkUrl.hostname === "127.0.0.1" || + sinkUrl.hostname === "::1"; + if (!isLocalhost) { + setStatus("Sink must be localhost"); + return; + } + if (sinkUrl.protocol !== "ws:" && sinkUrl.protocol !== "wss:") { + setStatus("Sink must use ws:// or wss://"); + return; + } + } catch { + setStatus("Invalid sink URL"); + return; + } + if (!("MediaRecorder" in window)) { setStatus("MediaRecorder unavailable"); return; diff --git a/bulk-test.ts b/bulk-test.ts index 47a4d2a..c17fb7b 100644 --- a/bulk-test.ts +++ b/bulk-test.ts @@ -1,4 +1,4 @@ -import { appendFileSync } from "node:fs"; +import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { MODELS, @@ -21,6 +21,7 @@ const CONCURRENCY = 100; const startTime = Date.now(); const LOGS_DIR = join(import.meta.dir, "logs"); +mkdirSync(LOGS_DIR, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const LOG_FILE = join(LOGS_DIR, `bulk-test-${timestamp}.log`); diff --git a/check-db.ts b/check-db.ts index 0d2d0a1..3e9fa31 100644 --- a/check-db.ts +++ b/check-db.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; -const db = new Database("quipslop.sqlite"); +const dbPath = process.env.DATABASE_PATH ?? "quipslop.sqlite"; +const db = new Database(dbPath); const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all(); console.log(rows); diff --git a/db.ts b/db.ts index 6555a05..3218698 100644 --- a/db.ts +++ b/db.ts @@ -12,17 +12,21 @@ db.exec(` data TEXT ); `); +db.exec("PRAGMA journal_mode = WAL;"); + +const insertRound = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)"); +const countRounds = db.query("SELECT COUNT(*) as count FROM rounds"); +const selectRoundsPage = db.prepare("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset"); +const selectAllRounds = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC"); export function saveRound(round: RoundState) { - const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)"); - insert.run({ $num: round.num, $data: JSON.stringify(round) }); + insertRound.run({ $num: round.num, $data: JSON.stringify(round) }); } export function getRounds(page: number = 1, limit: number = 10) { const offset = (page - 1) * limit; - const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number }; - const rows = db.query("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset") - .all({ $limit: limit, $offset: offset }) as { data: string }[]; + const countQuery = countRounds.get() as { count: number }; + const rows = selectRoundsPage.all({ $limit: limit, $offset: offset }) as { data: string }[]; return { rounds: rows.map(r => JSON.parse(r.data) as RoundState), total: countQuery.count, @@ -33,7 +37,7 @@ export function getRounds(page: number = 1, limit: number = 10) { } export function getAllRounds() { - const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[]; + const rows = selectAllRounds.all() as { data: string }[]; return rows.map(r => JSON.parse(r.data) as RoundState); } diff --git a/frontend.tsx b/frontend.tsx index d8a9ce8..c044fb3 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -199,11 +199,11 @@ function ContestantCard({ vote{voteCount !== 1 ? "s" : ""} - {voters.map((v, i) => { + {voters.map((v) => { const logo = getLogo(v.voter.name); return logo ? ( {v.voter.name} ) : ( ; + let reconnectAttempt = 0; let knownVersion: string | null = null; function connect() { ws = new WebSocket(wsUrl); - ws.onopen = () => setConnected(true); + ws.onopen = () => { + setConnected(true); + reconnectAttempt = 0; + }; ws.onclose = () => { setConnected(false); - reconnectTimer = setTimeout(connect, 2000); + const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000) + Math.random() * 1000; + reconnectAttempt++; + reconnectTimer = setTimeout(connect, delay); }; ws.onmessage = (e) => { const msg: ServerMessage = JSON.parse(e.data); diff --git a/game.ts b/game.ts index beb0abd..81b1cef 100644 --- a/game.ts +++ b/game.ts @@ -162,7 +162,7 @@ export async function withRetry( } export function isRealString(s: string, minLength = 5): boolean { - return s.length >= minLength; + return s.trim().length >= minLength; } export function cleanResponse(text: string): string { @@ -192,7 +192,7 @@ Come up with something ORIGINAL — don't copy these examples.`; export async function callGeneratePrompt(model: Model): Promise { log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id }); const system = buildPromptSystem(); - const { text, usage, reasoning } = await generateText({ + const { text, usage } = await generateText({ model: openrouter.chat(model.id), system, prompt: @@ -214,7 +214,7 @@ export async function callGenerateAnswer( modelId: model.id, prompt, }); - const { text, usage, reasoning } = await generateText({ + const { text, usage } = await generateText({ model: openrouter.chat(model.id), system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`, prompt: `Fill in the blank: ${prompt}`, @@ -239,18 +239,18 @@ export async function callVote( answerA: a.answer, answerB: b.answer, }); - const { text, usage, reasoning } = await generateText({ + const { text, usage } = await generateText({ model: openrouter.chat(voter.id), system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`, prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`, }); log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage }); - const cleaned = text.trim().toUpperCase(); - if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) { + const cleaned = text.trim().toUpperCase().charAt(0); + if (cleaned !== "A" && cleaned !== "B") { throw new Error(`Invalid vote: "${text.trim()}"`); } - return cleaned.startsWith("A") ? "A" : "B"; + return cleaned as "A" | "B"; } import { saveRound } from "./db.ts"; @@ -280,8 +280,9 @@ export async function runGame( const latest = state.completed.at(-1); const expectedR = latest ? latest.num + 1 : 1; if (r !== expectedR) { - r = expectedR; - endRound = r + runs - 1; + r = expectedR - 1; // subtract 1 because the for-loop increments r + endRound = expectedR + runs - 1; + continue; } const shuffled = shuffle([...MODELS]); @@ -451,13 +452,14 @@ export async function runGame( } rerender(); + // Archive round before the display delay to prevent data loss on crash + saveRound(round); + await new Promise((r) => setTimeout(r, 5000)); if (state.generation !== roundGeneration) { continue; } - // Archive round - saveRound(round); state.completed = [...state.completed, round]; state.active = null; rerender(); diff --git a/history.tsx b/history.tsx index c87d893..28eb419 100644 --- a/history.tsx +++ b/history.tsx @@ -180,17 +180,17 @@ function HistoryCard({ round }: { round: RoundState }) { {votesA} {votesA === 1 ? "vote" : "votes"}

- {votersA.map( - (v) => - getLogo(v.name) && ( - - ), - )} + {votersA.map((v) => { + const logo = getLogo(v.name); + return logo ? ( + + ) : null; + })}
@@ -215,17 +215,17 @@ function HistoryCard({ round }: { round: RoundState }) { {votesB} {votesB === 1 ? "vote" : "votes"}
- {votersB.map( - (v) => - getLogo(v.name) && ( - - ), - )} + {votersB.map((v) => { + const logo = getLogo(v.name); + return logo ? ( + + ) : null; + })}
@@ -244,18 +244,32 @@ function App() { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; + const controller = new AbortController(); setLoading(true); - fetch(`/api/history?page=${page}`) - .then((res) => res.json()) + setError(null); + fetch(`/api/history?page=${page}`, { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) .then((data) => { - setRounds(data.rounds); - setTotalPages(data.totalPages || 1); - setLoading(false); + if (!cancelled) { + setRounds(data.rounds); + setTotalPages(data.totalPages || 1); + setLoading(false); + } }) .catch((err) => { - setError(err.message); - setLoading(false); + if (!cancelled && err.name !== "AbortError") { + setError(err.message); + setLoading(false); + } }); + return () => { + cancelled = true; + controller.abort(); + }; }, [page]); return ( @@ -289,7 +303,7 @@ function App() { style={{ display: "flex", flexDirection: "column", gap: "32px" }} > {rounds.map((r) => ( - + ))} diff --git a/package.json b/package.json index 50f949b..ce2cf0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "quipslop", - "module": "index.ts", + "module": "server.ts", "type": "module", "private": true, "scripts": { @@ -8,7 +8,9 @@ "start:cli": "bun quipslop.tsx", "start:web": "bun --hot server.ts", "start:stream": "bun ./scripts/stream-browser.ts live", - "start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun" + "start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun", + "test": "bun test", + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/bun": "latest", diff --git a/prompts.ts b/prompts.ts index 9b853cf..f5a092b 100644 --- a/prompts.ts +++ b/prompts.ts @@ -563,7 +563,6 @@ export const ALL_PROMPTS = [ "A good way to get kicked out of a jazz band", "The best part about being an 1800s fur trapper", "A terrible drill sergeant would be constantly yelling ___", - "The title of the shortest book ever written", "Remember, when buying a new home, always make sure the previous owner didn't ___", "An unusual store: ___ R' US", 'A word that shouldn\'t come before "jerky"', @@ -851,18 +850,11 @@ export const ALL_PROMPTS = [ "A good replacement for Sea World's whale shows would be ___", "The worst bachelor party would involve ___", "What the world's most obnoxiously trendy brunch restaurant would be called", - "A strange thing to keep as a pet", "A job that doesn't exist now, but that somebody probably had in olden times", - "A good name for a sex robot", - "The worst reason to call 911", "What the hare did with his life after losing to the tortoise", "The best part about being Donald Trump", "A movie that needed more nudity", "The worst person who could sing the James Bond theme", "A new children's classic: The Velveteen ___", - "The title of a Goosebumps book that was never published", "What the S stands for in Ulysses S. Grant", - "A good name for a mint for your butt", - "A rejected Jelly Belly flavor", - "The worst catchphrase for a Star Trek captain", ] as const; diff --git a/server.ts b/server.ts index f8f19db..402c9dd 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,6 @@ import type { ServerWebSocket } from "bun"; import { timingSafeEqual } from "node:crypto"; +import { resolve } from "node:path"; import indexHtml from "./index.html"; import historyHtml from "./history.html"; import adminHtml from "./admin.html"; @@ -184,10 +185,9 @@ function clearAdminCookie(isSecure: boolean): string { return buildAdminCookie("", isSecure, 0); } -function getProvidedAdminSecret(req: Request, url: URL): string { - const headerOrQuery = - req.headers.get("x-admin-secret") ?? url.searchParams.get("secret"); - if (headerOrQuery) return headerOrQuery; +function getProvidedAdminSecret(req: Request, _url: URL): string { + const header = req.headers.get("x-admin-secret"); + if (header) return header; const cookies = parseCookies(req); return cookies[ADMIN_COOKIE] ?? ""; } @@ -262,8 +262,12 @@ const server = Bun.serve({ const ip = getClientIp(req, server); if (url.pathname.startsWith("/assets/")) { - const path = `./public${url.pathname}`; - const file = Bun.file(path); + const PUBLIC_DIR = resolve("./public"); + const safePath = resolve(`./public${url.pathname}`); + if (!safePath.startsWith(PUBLIC_DIR + "/") && safePath !== PUBLIC_DIR) { + return new Response("Forbidden", { status: 403 }); + } + const file = Bun.file(safePath); return new Response(file, { headers: { "Cache-Control": "public, max-age=604800, immutable", @@ -289,7 +293,7 @@ const server = Bun.serve({ const expected = process.env.ADMIN_SECRET; if (!expected) { - return new Response("ADMIN_SECRET is not configured", { status: 503 }); + return new Response("Service Unavailable", { status: 503 }); } let passcode = ""; @@ -425,8 +429,6 @@ const server = Bun.serve({ } if ( - url.pathname === "/api/pause" || - url.pathname === "/api/resume" || url.pathname === "/api/admin/pause" || url.pathname === "/api/admin/resume" ) { @@ -450,9 +452,6 @@ const server = Bun.serve({ } broadcast(); const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed"; - if (url.pathname === "/api/pause" || url.pathname === "/api/resume") { - return new Response(action, { status: 200 }); - } return new Response( JSON.stringify({ ok: true, action, ...getAdminSnapshot() }), { @@ -579,6 +578,14 @@ log("INFO", "server", `Web server started on port ${server.port}`, { // ── Start game ────────────────────────────────────────────────────────────── -runGame(runs, gameState, broadcast).then(() => { - console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); -}); +runGame(runs, gameState, broadcast) + .then(() => { + console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); + }) + .catch((err) => { + log("ERROR", "game", "Game loop crashed", { + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + console.error("Game loop crashed:", err); + });