179 lines
7.4 KiB
JavaScript
179 lines
7.4 KiB
JavaScript
import express from 'express';
|
||
import { HttpError } from './matchService.js';
|
||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||
|
||
const SESSION_HOURS = 12;
|
||
|
||
function bearer(req) {
|
||
const header = req.get('authorization') ?? '';
|
||
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||
}
|
||
|
||
/**
|
||
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
|
||
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
||
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
||
*/
|
||
export function createAdminRouter({ service, manager, config, settings }) {
|
||
const router = express.Router();
|
||
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||
|
||
const wrap = (fn) => async (req, res, next) => {
|
||
try {
|
||
const result = await fn(req, res);
|
||
res.json(result ?? { ok: true });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
};
|
||
|
||
const requireEnabled = (req, res, next) => {
|
||
if (config.adminPassword) return next();
|
||
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
|
||
};
|
||
|
||
const requireAdmin = (req, res, next) => {
|
||
const token = bearer(req);
|
||
const expires = token && sessions.get(token);
|
||
if (expires && expires > Date.now()) return next();
|
||
if (token) sessions.delete(token);
|
||
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
|
||
};
|
||
|
||
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
|
||
const adminViewer = (matchId, extra = {}) => ({
|
||
matchId,
|
||
role: 'organizer',
|
||
isOrganizer: true,
|
||
isCaptain: false,
|
||
isSpectator: false,
|
||
playerId: null,
|
||
team: null,
|
||
...extra,
|
||
});
|
||
|
||
router.use(requireEnabled);
|
||
|
||
router.post('/login', (req, res, next) => {
|
||
if (!loginLimit(`admin:${req.ip}`)) {
|
||
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
|
||
}
|
||
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
|
||
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
|
||
}
|
||
const token = randomToken();
|
||
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||
sessions.set(token, expiresAt);
|
||
return res.json({ token, expiresAt });
|
||
});
|
||
|
||
router.post('/logout', (req, res) => {
|
||
const token = bearer(req);
|
||
if (token) sessions.delete(token);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
||
|
||
// Discord-Meldungen: welche Ereignisse gemeldet werden
|
||
// Gameserver-Einstellungen: Vorgaben aus der .env, vom Admin überschreibbar
|
||
router.get('/server-settings', requireAdmin, wrap(() => ({ fields: settings.view() })));
|
||
router.put('/server-settings', requireAdmin, wrap(async (req) => {
|
||
const changed = settings.update(req.body?.values ?? {});
|
||
if (changed.includes('vetoStepSeconds')) service.startVetoTimer();
|
||
const live = changed.length ? await manager.applyLiveSettings(changed) : { updated: 0, failed: [] };
|
||
return { fields: settings.view(), changed, live };
|
||
}));
|
||
|
||
// Docker-Hosts: Liste wie CS2_DOCKER_HOSTS, gespeichert in der Datenbank
|
||
const hostView = () => ({
|
||
hosts: (manager.adapter.hosts ?? []).map((h) => ({
|
||
name: h.name,
|
||
dockerHost: h.dockerHost,
|
||
publicHost: h.publicHost,
|
||
rconHost: h.rconHost,
|
||
portStart: h.portRangeStart,
|
||
portEnd: h.portRangeEnd,
|
||
volume: h.volume,
|
||
})),
|
||
overridden: settings.hostsOverridden(),
|
||
editable: !!manager.adapter.setHosts,
|
||
image: config.docker.image,
|
||
pull: manager.adapter.pullStatus?.() ?? null,
|
||
});
|
||
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
|
||
router.put('/docker-hosts', requireAdmin, wrap((req) => {
|
||
const list = req.body?.hosts;
|
||
const hosts = settings.parseHosts(list);
|
||
manager.setDockerHosts(hosts);
|
||
settings.saveHosts(list, hosts);
|
||
return hostView();
|
||
}));
|
||
router.delete('/docker-hosts', requireAdmin, wrap(() => {
|
||
manager.setDockerHosts(settings.defaultHosts);
|
||
settings.resetHosts();
|
||
return hostView();
|
||
}));
|
||
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
||
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
|
||
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
|
||
manager.adapter.pullImage().catch(() => {});
|
||
return hostView();
|
||
}));
|
||
|
||
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
||
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
|
||
if (!(await service.notifier.send(test))) {
|
||
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||
}
|
||
return { ok: true };
|
||
}));
|
||
|
||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||
router.get('/matches', requireAdmin, wrap(async () => {
|
||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
||
}));
|
||
|
||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||
service.getMatch(req.params.id);
|
||
return fn(adminViewer(req.params.id), req.body ?? {}, req);
|
||
}));
|
||
|
||
action('finish', (v) => service.close(v, 'finished'));
|
||
action('cancel', (v) => service.close(v, 'cancelled'));
|
||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||
action('server/retry', (v) => manager.retry(v));
|
||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||
action('server/pause', (v) => manager.setPaused(v, true));
|
||
action('server/resume', (v) => manager.setPaused(v, false));
|
||
action('series/next', (v) => manager.loadNextMap(v));
|
||
action('veto/start', (v) => service.startVeto(v));
|
||
action('veto/action', (v, b) => service.vetoAction(
|
||
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||
));
|
||
action('kick', (v, b) => service.kick(v, b.playerId));
|
||
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||
|
||
// Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht.
|
||
router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => {
|
||
service.getMatch(req.params.id);
|
||
return { points: await manager.rollbackPoints(adminViewer(req.params.id)) };
|
||
}));
|
||
action('rollback', (v, b) => manager.rollback(v, b.file));
|
||
|
||
// Match endgültig löschen (Server wird vorher freigegeben)
|
||
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
|
||
await manager.deleteMatch(req.params.id);
|
||
return { ok: true };
|
||
}));
|
||
|
||
return router;
|
||
}
|