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)); // Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht. router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => { service.getMatch(req.params.id); return { points: await manager.rollbackPoints(adminViewer(req.params.id)) }; })); action('rollback', (v, b) => manager.rollback(v, b.file)); // 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; }