Files
Cs2-Mapveto/server/src/http.js
T

143 lines
5.8 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, 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, pools }));
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;
}