admin and higher limits
This commit is contained in:
330
admin.css
Normal file
330
admin.css
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111;
|
||||||
|
--border: #1c1c1c;
|
||||||
|
--text: #ededed;
|
||||||
|
--text-dim: #888;
|
||||||
|
--text-muted: #444;
|
||||||
|
--accent: #D97757;
|
||||||
|
--serif: 'DM Serif Display', Georgia, serif;
|
||||||
|
--sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin--centered {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-link img {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--login {
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 32px;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--login h1,
|
||||||
|
.panel-head h1 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: clamp(36px, 5vw, 48px);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--main {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head p,
|
||||||
|
.muted {
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.link-button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled),
|
||||||
|
.link-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled,
|
||||||
|
.link-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-links a,
|
||||||
|
.link-button {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-links a:hover,
|
||||||
|
.link-button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card__label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card__value {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(540px, 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 24px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.status-grid,
|
||||||
|
.actions {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.admin {
|
||||||
|
padding: 18px 14px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--main,
|
||||||
|
.panel--login {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid,
|
||||||
|
.actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
admin.html
Normal file
19
admin.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>quipslop Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="./admin.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./admin.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
378
admin.tsx
Normal file
378
admin.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./admin.css";
|
||||||
|
|
||||||
|
type AdminSnapshot = {
|
||||||
|
isPaused: boolean;
|
||||||
|
isRunningRound: boolean;
|
||||||
|
done: boolean;
|
||||||
|
completedInMemory: number;
|
||||||
|
persistedRounds: number;
|
||||||
|
viewerCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminResponse = { ok: true } & AdminSnapshot;
|
||||||
|
type Mode = "checking" | "locked" | "ready";
|
||||||
|
|
||||||
|
const RESET_TOKEN = "RESET";
|
||||||
|
|
||||||
|
async function readErrorMessage(res: Response): Promise<string> {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) return text;
|
||||||
|
return `Request failed (${res.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAdminJson(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<AdminResponse> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as AdminResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="status-card">
|
||||||
|
<div className="status-card__label">{label}</div>
|
||||||
|
<div className="status-card__value">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [mode, setMode] = useState<Mode>("checking");
|
||||||
|
const [snapshot, setSnapshot] = useState<AdminSnapshot | null>(null);
|
||||||
|
const [passcode, setPasscode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [isResetOpen, setIsResetOpen] = useState(false);
|
||||||
|
const [resetText, setResetText] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
requestAdminJson("/api/admin/status")
|
||||||
|
.then((data) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setSnapshot(data);
|
||||||
|
setMode("ready");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setSnapshot(null);
|
||||||
|
setMode("locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const busy = useMemo(() => pending !== null, [pending]);
|
||||||
|
|
||||||
|
async function onLogin(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setPending("login");
|
||||||
|
try {
|
||||||
|
const data = await requestAdminJson("/api/admin/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ passcode }),
|
||||||
|
});
|
||||||
|
setSnapshot(data);
|
||||||
|
setPasscode("");
|
||||||
|
setMode("ready");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to log in");
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runControl(path: string, task: string) {
|
||||||
|
setError(null);
|
||||||
|
setPending(task);
|
||||||
|
try {
|
||||||
|
const data = await requestAdminJson(path, { method: "POST" });
|
||||||
|
setSnapshot(data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Admin action failed";
|
||||||
|
if (message.toLowerCase().includes("unauthorized")) {
|
||||||
|
setMode("locked");
|
||||||
|
setSnapshot(null);
|
||||||
|
}
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onExport() {
|
||||||
|
setError(null);
|
||||||
|
setPending("export");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/export", { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get("content-disposition") ?? "";
|
||||||
|
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
|
||||||
|
const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`;
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Export failed";
|
||||||
|
if (message.toLowerCase().includes("unauthorized")) {
|
||||||
|
setMode("locked");
|
||||||
|
setSnapshot(null);
|
||||||
|
}
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReset() {
|
||||||
|
setError(null);
|
||||||
|
setPending("reset");
|
||||||
|
try {
|
||||||
|
const data = await requestAdminJson("/api/admin/reset", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ confirm: RESET_TOKEN }),
|
||||||
|
});
|
||||||
|
setSnapshot(data);
|
||||||
|
setResetText("");
|
||||||
|
setIsResetOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Reset failed");
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
setError(null);
|
||||||
|
setPending("logout");
|
||||||
|
try {
|
||||||
|
await fetch("/api/admin/logout", {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
setSnapshot(null);
|
||||||
|
setPasscode("");
|
||||||
|
setMode("locked");
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "checking") {
|
||||||
|
return (
|
||||||
|
<div className="admin admin--centered">
|
||||||
|
<div className="loading">Checking admin session...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "locked") {
|
||||||
|
return (
|
||||||
|
<div className="admin admin--centered">
|
||||||
|
<main className="panel panel--login">
|
||||||
|
<a href="/" className="logo-link">
|
||||||
|
<img src="/assets/logo.svg" alt="quipslop" />
|
||||||
|
</a>
|
||||||
|
<h1>Admin Access</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Enter your passcode once. A secure cookie will keep this browser
|
||||||
|
logged in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onLogin}
|
||||||
|
className="login-form"
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
>
|
||||||
|
<label htmlFor="passcode" className="field-label">
|
||||||
|
Passcode
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passcode"
|
||||||
|
type="password"
|
||||||
|
value={passcode}
|
||||||
|
onChange={(e) => setPasscode(e.target.value)}
|
||||||
|
className="text-input"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={busy || !passcode.trim()}
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
>
|
||||||
|
{pending === "login" ? "Checking..." : "Unlock Admin"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="quick-links">
|
||||||
|
<a href="/">Live Game</a>
|
||||||
|
<a href="/history">History</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin">
|
||||||
|
<header className="admin-header">
|
||||||
|
<a href="/" className="logo-link">
|
||||||
|
quipslop
|
||||||
|
</a>
|
||||||
|
<nav className="quick-links">
|
||||||
|
<a href="/">Live Game</a>
|
||||||
|
<a href="/history">History</a>
|
||||||
|
<button className="link-button" onClick={onLogout} disabled={busy}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="panel panel--main">
|
||||||
|
<div className="panel-head">
|
||||||
|
<h1>Admin Console</h1>
|
||||||
|
<p>
|
||||||
|
Pause/resume the game loop, export all data as JSON, or wipe all
|
||||||
|
stored data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<section className="status-grid" aria-live="polite">
|
||||||
|
<StatusCard
|
||||||
|
label="Engine"
|
||||||
|
value={snapshot?.isPaused ? "Paused" : "Running"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Active Round"
|
||||||
|
value={snapshot?.isRunningRound ? "In Progress" : "Idle"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Persisted Rounds"
|
||||||
|
value={String(snapshot?.persistedRounds ?? 0)}
|
||||||
|
/>
|
||||||
|
<StatusCard label="Viewers" value={String(snapshot?.viewerCount ?? 0)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="actions" aria-label="Admin actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={busy || Boolean(snapshot?.isPaused)}
|
||||||
|
onClick={() => runControl("/api/admin/pause", "pause")}
|
||||||
|
>
|
||||||
|
{pending === "pause" ? "Pausing..." : "Pause"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy || !snapshot?.isPaused}
|
||||||
|
onClick={() => runControl("/api/admin/resume", "resume")}
|
||||||
|
>
|
||||||
|
{pending === "resume" ? "Resuming..." : "Resume"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" disabled={busy} onClick={onExport}>
|
||||||
|
{pending === "export" ? "Exporting..." : "Export JSON"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setIsResetOpen(true)}
|
||||||
|
>
|
||||||
|
Reset Data
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{isResetOpen && (
|
||||||
|
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>Reset all data?</h2>
|
||||||
|
<p>
|
||||||
|
This permanently deletes every saved round and resets scores.
|
||||||
|
Current game flow is also paused.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Type <code>{RESET_TOKEN}</code> to continue.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resetText}
|
||||||
|
onChange={(e) => setResetText(e.target.value)}
|
||||||
|
className="text-input"
|
||||||
|
placeholder={RESET_TOKEN}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setIsResetOpen(false);
|
||||||
|
setResetText("");
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={busy || resetText !== RESET_TOKEN}
|
||||||
|
>
|
||||||
|
{pending === "reset" ? "Resetting..." : "Confirm Reset"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<App />);
|
||||||
5
db.ts
5
db.ts
@@ -36,3 +36,8 @@ export function getAllRounds() {
|
|||||||
const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[];
|
const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[];
|
||||||
return rows.map(r => JSON.parse(r.data) as RoundState);
|
return rows.map(r => JSON.parse(r.data) as RoundState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearAllRounds() {
|
||||||
|
db.exec("DELETE FROM rounds;");
|
||||||
|
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
||||||
|
}
|
||||||
|
|||||||
@@ -352,6 +352,11 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standings__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.standings__title {
|
.standings__title {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
19
frontend.tsx
19
frontend.tsx
@@ -36,6 +36,8 @@ type GameState = {
|
|||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
generation: number;
|
||||||
};
|
};
|
||||||
type ServerMessage = {
|
type ServerMessage = {
|
||||||
type: "state";
|
type: "state";
|
||||||
@@ -340,9 +342,14 @@ function Standings({
|
|||||||
<aside className="standings">
|
<aside className="standings">
|
||||||
<div className="standings__head">
|
<div className="standings__head">
|
||||||
<span className="standings__title">Standings</span>
|
<span className="standings__title">Standings</span>
|
||||||
|
<div className="standings__links">
|
||||||
<a href="/history" className="standings__link">
|
<a href="/history" className="standings__link">
|
||||||
History →
|
History
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin" className="standings__link">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="standings__list">
|
<div className="standings__list">
|
||||||
{sorted.map(([name, score], i) => {
|
{sorted.map(([name, score], i) => {
|
||||||
@@ -443,10 +450,20 @@ function App() {
|
|||||||
<a href="/" className="logo">
|
<a href="/" className="logo">
|
||||||
<img src="/assets/logo.svg" alt="quipslop" />
|
<img src="/assets/logo.svg" alt="quipslop" />
|
||||||
</a>
|
</a>
|
||||||
|
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
|
{state.isPaused && (
|
||||||
|
<div
|
||||||
|
className="viewer-pill"
|
||||||
|
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
Paused
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="viewer-pill" aria-live="polite">
|
<div className="viewer-pill" aria-live="polite">
|
||||||
<span className="viewer-pill__dot" />
|
<span className="viewer-pill__dot" />
|
||||||
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
|
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{state.done ? (
|
{state.done ? (
|
||||||
|
|||||||
46
game.ts
46
game.ts
@@ -72,6 +72,7 @@ export type GameState = {
|
|||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
generation: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── OpenRouter ──────────────────────────────────────────────────────────────
|
// ── OpenRouter ──────────────────────────────────────────────────────────────
|
||||||
@@ -254,8 +255,9 @@ export async function runGame(
|
|||||||
rerender: () => void,
|
rerender: () => void,
|
||||||
) {
|
) {
|
||||||
let startRound = 1;
|
let startRound = 1;
|
||||||
if (state.completed.length > 0) {
|
const lastCompletedRound = state.completed.at(-1);
|
||||||
startRound = state.completed[state.completed.length - 1].num + 1;
|
if (lastCompletedRound) {
|
||||||
|
startRound = lastCompletedRound.num + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endRound = startRound + runs - 1;
|
const endRound = startRound + runs - 1;
|
||||||
@@ -264,6 +266,7 @@ export async function runGame(
|
|||||||
while (state.isPaused) {
|
while (state.isPaused) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
const roundGeneration = state.generation;
|
||||||
|
|
||||||
const shuffled = shuffle([...MODELS]);
|
const shuffled = shuffle([...MODELS]);
|
||||||
const prompter = shuffled[0]!;
|
const prompter = shuffled[0]!;
|
||||||
@@ -300,11 +303,17 @@ export async function runGame(
|
|||||||
3,
|
3,
|
||||||
`R${r}:prompt:${prompter.name}`,
|
`R${r}:prompt:${prompter.name}`,
|
||||||
);
|
);
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
round.promptTask.finishedAt = Date.now();
|
round.promptTask.finishedAt = Date.now();
|
||||||
round.promptTask.result = prompt;
|
round.promptTask.result = prompt;
|
||||||
round.prompt = prompt;
|
round.prompt = prompt;
|
||||||
rerender();
|
rerender();
|
||||||
} catch {
|
} catch {
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
round.promptTask.finishedAt = Date.now();
|
round.promptTask.finishedAt = Date.now();
|
||||||
round.promptTask.error = "Failed after 3 attempts";
|
round.promptTask.error = "Failed after 3 attempts";
|
||||||
round.phase = "done";
|
round.phase = "done";
|
||||||
@@ -323,6 +332,9 @@ export async function runGame(
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
round.answerTasks.map(async (task) => {
|
round.answerTasks.map(async (task) => {
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const answer = await withRetry(
|
const answer = await withRetry(
|
||||||
() => callGenerateAnswer(task.model, round.prompt!),
|
() => callGenerateAnswer(task.model, round.prompt!),
|
||||||
@@ -330,15 +342,27 @@ export async function runGame(
|
|||||||
3,
|
3,
|
||||||
`R${r}:answer:${task.model.name}`,
|
`R${r}:answer:${task.model.name}`,
|
||||||
);
|
);
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
task.result = answer;
|
task.result = answer;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
task.error = "Failed to answer";
|
task.error = "Failed to answer";
|
||||||
task.result = "[no answer]";
|
task.result = "[no answer]";
|
||||||
}
|
}
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
task.finishedAt = Date.now();
|
task.finishedAt = Date.now();
|
||||||
rerender();
|
rerender();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vote phase ──
|
// ── Vote phase ──
|
||||||
round.phase = "voting";
|
round.phase = "voting";
|
||||||
@@ -350,6 +374,9 @@ export async function runGame(
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
round.votes.map(async (vote) => {
|
round.votes.map(async (vote) => {
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const showAFirst = Math.random() > 0.5;
|
const showAFirst = Math.random() > 0.5;
|
||||||
const first = showAFirst ? { answer: answerA } : { answer: answerB };
|
const first = showAFirst ? { answer: answerA } : { answer: answerB };
|
||||||
@@ -361,6 +388,9 @@ export async function runGame(
|
|||||||
3,
|
3,
|
||||||
`R${r}:vote:${vote.voter.name}`,
|
`R${r}:vote:${vote.voter.name}`,
|
||||||
);
|
);
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const votedFor = showAFirst
|
const votedFor = showAFirst
|
||||||
? result === "A"
|
? result === "A"
|
||||||
? contA
|
? contA
|
||||||
@@ -372,12 +402,21 @@ export async function runGame(
|
|||||||
vote.finishedAt = Date.now();
|
vote.finishedAt = Date.now();
|
||||||
vote.votedFor = votedFor;
|
vote.votedFor = votedFor;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
vote.finishedAt = Date.now();
|
vote.finishedAt = Date.now();
|
||||||
vote.error = true;
|
vote.error = true;
|
||||||
}
|
}
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
rerender();
|
rerender();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Score ──
|
// ── Score ──
|
||||||
let votesA = 0;
|
let votesA = 0;
|
||||||
@@ -397,6 +436,9 @@ export async function runGame(
|
|||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
if (state.generation !== roundGeneration) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Archive round
|
// Archive round
|
||||||
saveRound(round);
|
saveRound(round);
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -266,9 +266,14 @@ function App() {
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-title">Past Rounds</div>
|
<div className="page-title">Past Rounds</div>
|
||||||
|
<div className="page-links">
|
||||||
<a href="/" className="back-link">
|
<a href="/" className="back-link">
|
||||||
← Back to Game
|
← Back to Game
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin" className="back-link">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ function Game({ runs }: { runs: number }) {
|
|||||||
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
||||||
done: false,
|
done: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
|
generation: 0,
|
||||||
});
|
});
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const rerender = useCallback(() => setTick((t) => t + 1), []);
|
const rerender = useCallback(() => setTick((t) => t + 1), []);
|
||||||
|
|||||||
241
server.ts
241
server.ts
@@ -2,7 +2,8 @@ import type { ServerWebSocket } from "bun";
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import indexHtml from "./index.html";
|
import indexHtml from "./index.html";
|
||||||
import historyHtml from "./history.html";
|
import historyHtml from "./history.html";
|
||||||
import { getRounds, getAllRounds } from "./db.ts";
|
import adminHtml from "./admin.html";
|
||||||
|
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
LOG_FILE,
|
LOG_FILE,
|
||||||
@@ -52,6 +53,7 @@ const gameState: GameState = {
|
|||||||
scores: initialScores,
|
scores: initialScores,
|
||||||
done: false,
|
done: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
|
generation: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Guardrails ──────────────────────────────────────────────────────────────
|
// ── Guardrails ──────────────────────────────────────────────────────────────
|
||||||
@@ -71,7 +73,7 @@ const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
|
|||||||
process.env.ADMIN_LIMIT_PER_MIN,
|
process.env.ADMIN_LIMIT_PER_MIN,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000);
|
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
|
||||||
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
||||||
const MAX_HISTORY_PAGE = parsePositiveInt(
|
const MAX_HISTORY_PAGE = parsePositiveInt(
|
||||||
process.env.MAX_HISTORY_PAGE,
|
process.env.MAX_HISTORY_PAGE,
|
||||||
@@ -86,6 +88,8 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
|
|||||||
process.env.MAX_HISTORY_CACHE_KEYS,
|
process.env.MAX_HISTORY_CACHE_KEYS,
|
||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
|
const ADMIN_COOKIE = "quipslop_admin";
|
||||||
|
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
const requestWindows = new Map<string, number[]>();
|
const requestWindows = new Map<string, number[]>();
|
||||||
const wsByIp = new Map<string, number>();
|
const wsByIp = new Map<string, number>();
|
||||||
@@ -136,11 +140,59 @@ function secureCompare(a: string, b: string): boolean {
|
|||||||
return timingSafeEqual(aBuf, bBuf);
|
return timingSafeEqual(aBuf, bBuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCookies(req: Request): Record<string, string> {
|
||||||
|
const raw = req.headers.get("cookie");
|
||||||
|
if (!raw) return {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
for (const pair of raw.split(";")) {
|
||||||
|
const idx = pair.indexOf("=");
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
const key = pair.slice(0, idx).trim();
|
||||||
|
const val = pair.slice(idx + 1).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
try {
|
||||||
|
cookies[key] = decodeURIComponent(val);
|
||||||
|
} catch {
|
||||||
|
cookies[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminCookie(
|
||||||
|
passcode: string,
|
||||||
|
isSecure: boolean,
|
||||||
|
maxAgeSeconds = ADMIN_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
`${ADMIN_COOKIE}=${encodeURIComponent(passcode)}`,
|
||||||
|
"Path=/",
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Strict",
|
||||||
|
`Max-Age=${maxAgeSeconds}`,
|
||||||
|
];
|
||||||
|
if (isSecure) {
|
||||||
|
parts.push("Secure");
|
||||||
|
}
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const cookies = parseCookies(req);
|
||||||
|
return cookies[ADMIN_COOKIE] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function isAdminAuthorized(req: Request, url: URL): boolean {
|
function isAdminAuthorized(req: Request, url: URL): boolean {
|
||||||
const expected = process.env.ADMIN_SECRET;
|
const expected = process.env.ADMIN_SECRET;
|
||||||
if (!expected) return false;
|
if (!expected) return false;
|
||||||
const provided =
|
const provided = getProvidedAdminSecret(req, url);
|
||||||
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
|
|
||||||
if (!provided) return false;
|
if (!provided) return false;
|
||||||
return secureCompare(provided, expected);
|
return secureCompare(provided, expected);
|
||||||
}
|
}
|
||||||
@@ -178,6 +230,17 @@ function broadcast() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdminSnapshot() {
|
||||||
|
return {
|
||||||
|
isPaused: gameState.isPaused,
|
||||||
|
isRunningRound: Boolean(gameState.active),
|
||||||
|
done: gameState.done,
|
||||||
|
completedInMemory: gameState.completed.length,
|
||||||
|
persistedRounds: getRounds(1, 1).total,
|
||||||
|
viewerCount: clients.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Server ──────────────────────────────────────────────────────────────────
|
// ── Server ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
|
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
|
||||||
@@ -187,8 +250,9 @@ const server = Bun.serve<WsData>({
|
|||||||
routes: {
|
routes: {
|
||||||
"/": indexHtml,
|
"/": indexHtml,
|
||||||
"/history": historyHtml,
|
"/history": historyHtml,
|
||||||
|
"/admin": adminHtml,
|
||||||
},
|
},
|
||||||
fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const ip = getClientIp(req, server);
|
const ip = getClientIp(req, server);
|
||||||
|
|
||||||
@@ -207,7 +271,108 @@ const server = Bun.serve<WsData>({
|
|||||||
return new Response("ok", { status: 200 });
|
return new Response("ok", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
|
if (url.pathname === "/api/admin/login") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = process.env.ADMIN_SECRET;
|
||||||
|
if (!expected) {
|
||||||
|
return new Response("ADMIN_SECRET is not configured", { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let passcode = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
passcode = String((body as Record<string, unknown>).passcode ?? "");
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passcode || !secureCompare(passcode, expected)) {
|
||||||
|
return new Response("Invalid passcode", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecure = url.protocol === "https:";
|
||||||
|
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": buildAdminCookie(passcode, isSecure),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/logout") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const isSecure = url.protocol === "https:";
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": clearAdminCookie(isSecure),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/status") {
|
||||||
|
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
if (!isAdminAuthorized(req, url)) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/export") {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "GET" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
if (!isAdminAuthorized(req, url)) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
rounds: getAllRounds(),
|
||||||
|
state: gameState,
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(payload, null, 2), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/reset") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
status: 405,
|
status: 405,
|
||||||
@@ -221,16 +386,76 @@ const server = Bun.serve<WsData>({
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/pause") {
|
let confirm = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
confirm = String((body as Record<string, unknown>).confirm ?? "");
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
if (confirm !== "RESET") {
|
||||||
|
return new Response("Confirmation token must be RESET", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllRounds();
|
||||||
|
historyCache.clear();
|
||||||
|
gameState.completed = [];
|
||||||
|
gameState.active = null;
|
||||||
|
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
|
gameState.done = false;
|
||||||
|
gameState.isPaused = true;
|
||||||
|
gameState.generation += 1;
|
||||||
|
broadcast();
|
||||||
|
|
||||||
|
log("WARN", "admin", "Database reset requested", { ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === "/api/pause" ||
|
||||||
|
url.pathname === "/api/resume" ||
|
||||||
|
url.pathname === "/api/admin/pause" ||
|
||||||
|
url.pathname === "/api/admin/resume"
|
||||||
|
) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
if (!isAdminAuthorized(req, url)) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/pause")) {
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
} else {
|
} else {
|
||||||
gameState.isPaused = false;
|
gameState.isPaused = false;
|
||||||
}
|
}
|
||||||
broadcast();
|
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(
|
return new Response(
|
||||||
url.pathname === "/api/pause" ? "Paused" : "Resumed",
|
JSON.stringify({ ok: true, action, ...getAdminSnapshot() }),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user