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.
This commit is contained in:
1 parent
30ec5359b1
commit
46d1954a6d
8 files changed
+446
No files matched your search
@@ -0,0 +1,111 @@
|
||||
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 }) {
|
||||
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 }));
|
||||
|
||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||
router.get('/matches', requireAdmin, wrap(() => {
|
||||
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))) };
|
||||
}));
|
||||
|
||||
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('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));
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export function loadConfig(overrides = {}) {
|
||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||
streamKey: str('STREAM_KEY'),
|
||||
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
||||
adminPassword: str('ADMIN_PASSWORD'),
|
||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||
trustProxy: bool('TRUST_PROXY', false),
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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') ?? '';
|
||||
@@ -31,6 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||
});
|
||||
|
||||
router.use(express.json({ limit: '32kb' }));
|
||||
router.use('/admin', createAdminRouter({ service, manager, config }));
|
||||
|
||||
const limiters = {
|
||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||
|
||||
@@ -86,6 +86,17 @@ export class ServerManager {
|
||||
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
||||
}
|
||||
|
||||
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||||
async deleteMatch(matchId) {
|
||||
this.service.getMatch(matchId);
|
||||
clearTimeout(this.autoAdvance.get(matchId));
|
||||
this.autoAdvance.delete(matchId);
|
||||
this.simulator.stop(matchId);
|
||||
await this.stop(matchId, 'Match vom Admin gelöscht');
|
||||
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||||
this.service.emit('changed', matchId);
|
||||
}
|
||||
|
||||
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
||||
async idle(timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -377,6 +377,52 @@ test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Sc
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
|
||||
try {
|
||||
const { id } = (await call('POST', '/matches', {
|
||||
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
|
||||
})).body;
|
||||
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
|
||||
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
|
||||
|
||||
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
|
||||
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||
const admin = { token: login.token };
|
||||
assert.ok(login.token);
|
||||
|
||||
const list = (await call('GET', '/admin/matches', admin)).body.matches;
|
||||
const mine = list.find((m) => m.id === id);
|
||||
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
|
||||
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
|
||||
|
||||
// Ein Match abbrechen wie ein Admin
|
||||
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||
|
||||
// Endgültig löschen
|
||||
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
|
||||
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
|
||||
|
||||
// Abmelden: Token ist danach ungültig
|
||||
await call('POST', '/admin/logout', admin);
|
||||
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
|
||||
const { call, close } = await setup({ adminPassword: '' });
|
||||
try {
|
||||
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.body.code, 'admin_disabled');
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user