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.
110 lines
4.1 KiB
JavaScript
110 lines
4.1 KiB
JavaScript
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;
|
||
}
|