Files
Cs2-Mapveto/server/src/http.js
T
SPP UserandClaude Haiku 5.5 330048acfc Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:44 +01:00

140 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }) {
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 }));
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() })));
// 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;
}