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>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 16:50:22 +01:00
commit 104aa1a67b
27 files changed
+6355

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
import express from 'express';
import { HttpError } from './matchService.js';
import { publicPools } from './mapPools.js';
import { createRateLimiter, safeEqual } from './security.js';
function bearer(req) {
const header = req.get('authorization') ?? '';
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}
export function createApiRouter({ service, manager, config, pools, adapter }) {
const router = express.Router();
router.use(express.json({ limit: '32kb' }));
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, bearer(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() })));
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;
}