Compare commits

..
Author SHA1 Message Date
SPP UserandClaude Haiku 5.5 726974fe82 AMP und feste Host-Namen aus Doku und Tests entfernen
Der Docker-Host ist beliebig; Beispiele nutzen jetzt DOCKER-HOST-IP bzw.
den SSH-Alias docker-host statt eines Rechnernamens. Testfixture heißt
echte-logs-wingman.log.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:54:38 +01:00
SPP UserandClaude Haiku 5.5 413df03237 Stack unabhängig vom Host: deploy/compose.yml mit .env
CS2 Mapveto startet jetzt auf jedem Docker-Host (Linux) mit
'docker compose --env-file deploy/.env -f deploy/compose.yml up -d'.
Die Adresse kommt aus HOST_IP, nichts ist mehr fest eingetragen.
Anleitung für einen neuen Server: Image laden oder bauen, Spieldateien
kopieren oder neu laden, konfigurieren, starten.

Geprüft auf amp-cluster mit Demo-Adapter: Stack startet, Webseite
antwortet, Stack wieder entfernt, Spieldateien unverändert.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:46:51 +01:00
SPP UserandClaude Opus 5.5 d78f399612 CS2 Mapveto als ein Container (Dockerfile, compose.yml, Anleitung)
Ein Image mit Webseite, Backend, SQLite und Docker-Client; startet
Match-Container über /var/run/docker.sock auf demselben Host. Tests
laufen beim Bauen mit. Auf amp-cluster gebaut (301 MB) und geprüft.

Dazu: Testbefehl für Node 22 angepasst, beim Beenden werden ausstehende
Live-Updates verworfen und laufende Server-Aktionen abgewartet, damit nach
dem Schließen der Datenbank nichts mehr darauf zugreift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:52 +01:00
SPP UserandClaude Opus 5.5 6ee2de5a46 Docker-Warteschlange: Update-Check per RCON statt nur per Logzeile erkennen
Die Zeile 'Starting CS2 Dedicated Server' rutscht nach dem Serverstart
schnell aus den letzten Logzeilen. Der nächste Match-Container wartete
dadurch bis zur Zeitgrenze (20 min). Jetzt gilt ein Container als fertig,
sobald er per RCON antwortet; die Logzeile bleibt als zweite Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:16:57 +01:00
SPP UserandClaude Opus 5.5 7c74c6c4eb Docker-Adapter mit echtem Host getestet, zwei Fehler behoben
- CSTV belegt einen Spielerplatz: Container starten jetzt mit 6 bzw. 12
  Plätzen (Spieler + CSTV + Reserve), sonst kam der letzte Spieler nicht drauf
- 'Live-Daten empfangen' wird auch in der Aufwärmphase angezeigt, wenn
  der Server Logs liefert, die den Spielstand noch nicht ändern
- Anleitung: Serverliste, Plätze, Bots zum Testen

Getestet auf amp-cluster (Docker per SSH, LAN-Modus): Container-Start,
Veto, Konfiguration, Live-Runden, automatischer Mapwechsel, Entfernen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:09:43 +01:00
SPP UserandClaude Opus 5.5 59d6dc7177 Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander
Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:54:30 +01:00
SPP UserandClaude Opus 5.5 8996294d7e Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:32:53 +01:00
SPP UserandClaude Opus 5.5 54bfe198d8 Aufwärmphase vor mp_warmup_end als beendet markieren
CS2 sendet Match_Start sofort nach mp_warmup_end, oft bevor die
RCON-Antwort da ist. Bei fehlgeschlagenem Befehl wird die Markierung
zurückgenommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:23:53 +01:00
SPP UserandClaude Opus 5.5 43dc5b837a Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:20:10 +01:00
SPP UserandClaude Opus 5.5 5fade769e7 Live-Ergebnisse aus CS2-Serverlogs (Backend)
Server bekommt beim Konfigurieren logaddress_add_http mit geheimem
Match-Schlüssel. Ein Parser wertet Rundenende, Seitenzuordnung, Matchstart
und Spielende aus; Stand, Rundenverlauf und Sieger werden pro Map gespeichert
und live verteilt. Der Demo-Adapter simuliert nach der Aufwärmphase einen
Matchverlauf über dieselben Logzeilen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:54:23 +01:00
SPP UserandClaude Opus 5.5 5ea5638115 Anleitung: CS2-Server mit Portainer und dauerhaftem Volume
docs/portainer-cs2.md beschreibt Stack, einmaligen Download, Updates ohne
Neu-Download, Anbindung per Static- oder Docker-Adapter, mehrere Server
und Fehlersuche. Standard-Image auf joedwards32/cs2 umgestellt, da
cm2network/cs2 nicht mehr gepflegt wird.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:07:17 +01:00
SPP UserandClaude Opus 5.5 4152f9c0a6 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:53:39 +01:00
SPP UserandClaude Opus 5.5 470b12f232 Größere Map-Kacheln und Ban/Pick-Leiste
Ab dem Veto bekommt das Map-Board die volle Breite (4 Spalten), die
Teamkarten rücken darunter. Die Schrittleiste zeigt pro Schritt das Team.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:45:41 +01:00
SPP UserandClaude Opus 5.5 8a0a1309a2 MaxLAN-Branding: Logo in der Kopfzeile, Farben aus dem Logo
Team A in Logo-Blau, Team B in Logo-Rot, schwarze Kopfzeile mit rotem
Abschluss; CT/T-Farben aus den Emblemen, Warnungen in Orange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:42:38 +01:00
SPP UserandClaude Opus 5.5 9d63799c48 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:34:03 +01:00
SPP UserandClaude Opus 5.5 54168ae763 Map-Bilder und Seitenwahl mit Emblemen
Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:23:55 +01:00
SPP UserandClaude Opus 5.5 3a790ce0b1 Ein bereiter Spieler pro Team reicht standardmäßig für den Veto-Start
REQUIRE_FULL_TEAMS ist jetzt standardmäßig false; vollständige Teams
lassen sich weiterhin per Umgebungsvariable erzwingen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:11:46 +01:00
SPP UserandClaude Opus 5.5 3999c4b111 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:09:31 +01:00
SPP UserandClaude Opus 5.5 568ccf9b09 Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:02:18 +01:00
SPP UserandClaude Opus 5.5 104aa1a67b Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:50:22 +01:00
8 changed files with 0 additions and 446 deletions

No files matched your search

-3
View File
@@ -6,7 +6,6 @@ import CreateMatch from './pages/CreateMatch.jsx';
import MatchPage from './pages/MatchPage.jsx';
import StreamPage from './pages/StreamPage.jsx';
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
import AdminPage from './pages/AdminPage.jsx';
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
export default function App() {
@@ -43,7 +42,6 @@ function MainLayout() {
<nav>
<Link to="/" className="nav-link">Übersicht</Link>
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
<Link to="/admin" className="nav-link">Admin</Link>
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
</nav>
</header>
@@ -52,7 +50,6 @@ function MainLayout() {
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/neu" element={<CreateMatch config={config} />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/m/:id" element={<MatchPage />} />
<Route path="/join/:code" element={<JoinPage config={config} />} />
<Route path="/watch/:code" element={<WatchPage />} />
-248
View File
@@ -1,248 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
import CopyField from '../components/CopyField.jsx';
const KEY = 'cs2mv:admin';
const REFRESH_MS = 3000;
const getToken = () => {
try {
return sessionStorage.getItem(KEY);
} catch {
return null;
}
};
const storeToken = (t) => {
try {
if (t) sessionStorage.setItem(KEY, t);
else sessionStorage.removeItem(KEY);
} catch {
/* ignorieren */
}
};
export default function AdminPage() {
const [token, setToken] = useState(getToken);
const [matches, setMatches] = useState(null);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null);
const logout = useCallback(() => {
api('/admin/logout', { method: 'POST', token }).catch(() => {});
storeToken(null);
setToken(null);
setMatches(null);
}, [token]);
const load = useCallback(async () => {
try {
const res = await api('/admin/matches', { token });
setMatches(res.matches);
setError(null);
} catch (err) {
if (err.status === 401) {
storeToken(null);
setToken(null);
} else {
setError(err.message);
}
}
}, [token]);
useEffect(() => {
if (!token) return undefined;
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [token, load]);
useEffect(() => {
if (!notice) return undefined;
const t = setTimeout(() => setNotice(null), 5000);
return () => clearTimeout(t);
}, [notice]);
if (!token) return <AdminLogin onLogin={(t) => { storeToken(t); setToken(t); }} />;
/** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */
const run = async ({ method = 'POST', path, body, confirm: question, success }) => {
if (question && !window.confirm(question)) return;
setBusy(path);
try {
await api(`/admin${path}`, { method, body, token });
setNotice({ tone: 'ok', text: success ?? 'Erledigt' });
load();
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
};
return (
<div className="admin">
<header className="admin-head">
<div>
<h1>Admin</h1>
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
</div>
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header>
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>}
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
<div className="admin-list">
{matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} />
))}
</div>
</div>
);
}
function AdminLogin({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await api('/admin/login', { method: 'POST', body: { password } });
onLogin(res.token);
} catch (err) {
setError(err.message);
setBusy(false);
}
};
return (
<form className="create admin-login" onSubmit={submit}>
<h1>Admin-Anmeldung</h1>
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
<label className="field">
<span>Passwort</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
</label>
{error && <div className="alert alert-error">{error}</div>}
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
</form>
);
}
function MatchAdminCard({ m, run, busy }) {
const [open, setOpen] = useState(false);
const server = serverLabel(m.server);
const closed = ['finished', 'cancelled'].includes(m.status);
const id = m.id;
const btn = (label, cfg, cls = '') => (
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
{label}
</button>
);
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
return (
<section className={`panel admin-card status-${m.status}`}>
<div className="admin-card-head">
<div>
<strong className="team-a">{m.teams.A.name}</strong>
<span className="vs"> vs. </span>
<strong className="team-b">{m.teams.B.name}</strong>
<div className="small muted">
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div>
</div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div>
<div className="admin-actions">
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
</div>
{open && (
<div className="admin-details">
<div className="admin-grid">
<div>
<h3>Einladungen</h3>
<div className="small muted">Team A</div>
<CopyField value={m.organizer.links.A} />
<div className="small muted">Team B</div>
<CopyField value={m.organizer.links.B} />
<div className="small muted">Zuschauer</div>
<CopyField value={m.organizer.links.spectator} />
<div className="small muted">Stream-Ansicht</div>
<CopyField value={m.organizer.links.stream} />
<div className="row">
{['A', 'B', 'spectator'].map((t) => (
<button key={t} className="link-btn" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
</button>
))}
</div>
</div>
<div>
<h3>Server</h3>
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
</div>
</div>
<h3>Spieler</h3>
<div className="admin-players">
{['A', 'B'].map((t) => (
<div key={t}>
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
<ul>
{m.teams[t].players.map((p) => (
<li key={p.id}>
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
{!closed && (
<button className="link-btn danger" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
Entfernen
</button>
)}
</li>
))}
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
</ul>
</div>
))}
</div>
{m.veto && !m.veto.done && m.veto.current && (
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
)}
<h3>Verlauf</h3>
<ol className="admin-log">
{m.events.slice(0, 15).map((e, i) => (
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
))}
</ol>
</div>
)}
</section>
);
}
-23
View File
@@ -483,26 +483,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
@media (max-width: 640px) {
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
}
/* ---------- Admin ---------- */
.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.admin-list { display: grid; gap: 14px; }
.admin-card { border-left: 4px solid var(--line-strong); }
.admin-card.status-live { border-left-color: var(--brand-red); }
.admin-card.status-veto { border-left-color: var(--pending); }
.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.admin-card-head strong { font: 700 1.5rem var(--display); }
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; }
.admin-details h3 { margin-top: 6px; }
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; }
.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); }
.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; }
.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; }
.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); }
.admin-log time { color: var(--muted); margin-right: 8px; }
.admin-login { margin-top: 40px; }
.danger { color: var(--error); }
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } }
-111
View File
@@ -1,111 +0,0 @@
import express from 'express';
import { HttpError } from './matchService.js';
import { createRateLimiter, randomToken, safeEqual } from './security.js';
const SESSION_HOURS = 12;
function bearer(req) {
const header = req.get('authorization') ?? '';
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}
/**
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
*/
export function createAdminRouter({ service, manager, config }) {
const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
const wrap = (fn) => async (req, res, next) => {
try {
const result = await fn(req, res);
res.json(result ?? { ok: true });
} catch (err) {
next(err);
}
};
const requireEnabled = (req, res, next) => {
if (config.adminPassword) return next();
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
};
const requireAdmin = (req, res, next) => {
const token = bearer(req);
const expires = token && sessions.get(token);
if (expires && expires > Date.now()) return next();
if (token) sessions.delete(token);
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
};
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
const adminViewer = (matchId, extra = {}) => ({
matchId,
role: 'organizer',
isOrganizer: true,
isCaptain: false,
isSpectator: false,
playerId: null,
team: null,
...extra,
});
router.use(requireEnabled);
router.post('/login', (req, res, next) => {
if (!loginLimit(`admin:${req.ip}`)) {
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
}
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
}
const token = randomToken();
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
sessions.set(token, expiresAt);
return res.json({ token, expiresAt });
});
router.post('/logout', (req, res) => {
const token = bearer(req);
if (token) sessions.delete(token);
res.json({ ok: true });
});
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
}));
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
service.getMatch(req.params.id);
return fn(adminViewer(req.params.id), req.body ?? {}, req);
}));
action('finish', (v) => service.close(v, 'finished'));
action('cancel', (v) => service.close(v, 'cancelled'));
action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/retry', (v) => manager.retry(v));
action('server/warmup-end', (v) => manager.endWarmup(v));
action('series/next', (v) => manager.loadNextMap(v));
action('veto/start', (v) => service.startVeto(v));
action('veto/action', (v, b) => service.vetoAction(
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
));
action('kick', (v, b) => service.kick(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
// Match endgültig löschen (Server wird vorher freigegeben)
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
await manager.deleteMatch(req.params.id);
return { ok: true };
}));
return router;
}
-2
View File
@@ -41,8 +41,6 @@ export function loadConfig(overrides = {}) {
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
trustProxy: bool('TRUST_PROXY', false),
-2
View File
@@ -2,7 +2,6 @@ import express from 'express';
import { HttpError } from './matchService.js';
import { publicPools } from './mapPools.js';
import { createRateLimiter, safeEqual } from './security.js';
import { createAdminRouter } from './adminRoutes.js';
function bearer(req) {
const header = req.get('authorization') ?? '';
@@ -32,7 +31,6 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
});
router.use(express.json({ limit: '32kb' }));
router.use('/admin', createAdminRouter({ service, manager, config }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
-11
View File
@@ -86,17 +86,6 @@ export class ServerManager {
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
}
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
async deleteMatch(matchId) {
this.service.getMatch(matchId);
clearTimeout(this.autoAdvance.get(matchId));
this.autoAdvance.delete(matchId);
this.simulator.stop(matchId);
await this.stop(matchId, 'Match vom Admin gelöscht');
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
this.service.emit('changed', matchId);
}
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
async idle(timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
-46
View File
@@ -377,52 +377,6 @@ test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Sc
}
});
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
try {
const { id } = (await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
})).body;
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
const admin = { token: login.token };
assert.ok(login.token);
const list = (await call('GET', '/admin/matches', admin)).body.matches;
const mine = list.find((m) => m.id === id);
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
// Ein Match abbrechen wie ein Admin
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
assert.equal(app.service.getMatch(id).status, 'cancelled');
// Endgültig löschen
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
// Abmelden: Token ist danach ungültig
await call('POST', '/admin/logout', admin);
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
} finally {
await close();
}
});
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
const { call, close } = await setup({ adminPassword: '' });
try {
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
assert.equal(res.status, 404);
assert.equal(res.body.code, 'admin_disabled');
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {