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') ?? ''; return header.startsWith('Bearer ') ? header.slice(7).trim() : null; } /** Bis zu zwei Zugänge: Authorization (Organisator, Spieler oder Zuschauer) und X-Player-Token. */ function accessTokens(req) { return [bearer(req), req.get('x-player-token')?.trim() || null]; } export function createApiRouter({ service, manager, config, pools, adapter, settings, knife }) { const router = express.Router(); // Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet. // Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt. router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => { try { const body = typeof req.body === 'string' ? req.body : ''; // LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`); service.ingestLogs(req.params.id, req.params.key, body); res.status(200).end(); } catch (err) { res.status(err.status ?? 500).end(); } }); router.use(express.json({ limit: '32kb' })); router.use('/admin', createAdminRouter({ service, manager, config, settings, knife })); const limiters = { create: createRateLimiter({ windowMs: 60_000, max: 10 }), code: createRateLimiter({ windowMs: 60_000, max: 20 }), action: createRateLimiter({ windowMs: 60_000, max: 120 }), }; const limit = (name) => (req, res, next) => { if (!limiters[name](`${name}:${req.ip}`)) { return next(new HttpError(429, 'Zu viele Anfragen – bitte kurz warten', 'rate_limited')); } next(); }; const wrap = (fn) => async (req, res, next) => { try { const result = await fn(req, res); if (!res.headersSent) res.json(result ?? { ok: true }); } catch (err) { next(err); } }; // Ermittelt die Rolle des Aufrufers für das Match in der URL. const viewerFor = (req) => { const viewer = service.resolveViewer(req.params.id, accessTokens(req)); service.touch(req.params.id); return viewer; }; router.get('/config', wrap(() => ({ pools: publicPools(pools), createRequiresPassword: Boolean(config.createMatchPassword), requireFullTeams: config.requireFullTeams, server: { adapter: adapter.name, simulated: adapter.simulated, idleTimeoutMinutes: config.idleTimeoutMinutes }, }))); router.get('/matches', wrap(() => ({ matches: service.listMatches() }))); // Messer-Server für die Seitenwahl: offen, ohne Passwort, deshalb für alle sichtbar router.get('/knife-servers', wrap(() => ({ servers: knife.list().filter((k) => k.status === 'running') }))); // Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key= router.get('/stream/overview', wrap((req) => { if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) { throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden'); } return service.streamOverview(); })); router.post('/matches', limit('create'), wrap((req) => { const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {}; if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) { throw new HttpError(403, 'Falsches Passwort zum Erstellen von Matches', 'forbidden'); } const { id, organizerToken } = service.createMatch({ teamA, teamB, mode, format, vetoStart }); return { id, organizerToken }; })); router.post('/access', limit('code'), wrap((req) => { const { matchId, access, team, match } = service.lookupCode(req.body?.code); return { matchId, access, team, teamName: team ? service.teamName(match, team) : null, title: `${match.team_a_name} vs. ${match.team_b_name}`, status: match.status, }; })); router.get('/matches/:id', wrap((req) => service.snapshot(req.params.id, viewerFor(req)))); router.post('/matches/:id/join', limit('code'), wrap((req) => { service.getMatch(req.params.id); return service.join(req.params.id, req.body ?? {}); })); const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {}))); action('ready', (v, b) => service.setReady(v, Boolean(b.ready))); action('leave', (v) => service.leave(v)); action('kick', (v, b) => service.kick(v, b.playerId)); action('captain', (v, b) => service.setCaptain(v, b.playerId)); action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); action('veto/start', (v) => service.startVeto(v)); action('veto/action', (v, b) => service.vetoAction(v, b)); action('server/retry', (v) => manager.retry(v)); action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/warmup-end', (v) => manager.endWarmup(v)); action('series/next', (v) => manager.loadNextMap(v)); action('finish', (v) => service.close(v, 'finished')); action('cancel', (v) => service.close(v, 'cancelled')); router.use((req, res, next) => next(new HttpError(404, 'Unbekannter Endpunkt', 'not_found'))); // eslint-disable-next-line no-unused-vars router.use((err, req, res, next) => { const status = err.status ?? 500; if (status >= 500) console.error(err); res.status(status).json({ error: status >= 500 && !err.code ? 'Interner Fehler' : err.message, code: err.code ?? 'error', }); }); return router; }