Organisator kann im selben Browser selbst mitspielen

Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
This commit is contained in:
mregosar committed 2026-10-25 17:53:39 +01:00
1 parent 3a74e16bb8
commit 8be8b3d879
15 files changed
+248 -59

No files matched your search

+6 -1
View File
@@ -8,6 +8,11 @@ function bearer(req) {
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 }) {
const router = express.Router();
router.use(express.json({ limit: '32kb' }));
@@ -35,7 +40,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
// Ermittelt die Rolle des Aufrufers für das Match in der URL.
const viewerFor = (req) => {
const viewer = service.resolveViewer(req.params.id, bearer(req));
const viewer = service.resolveViewer(req.params.id, accessTokens(req));
service.touch(req.params.id);
return viewer;
};
+45 -20
View File
@@ -22,7 +22,6 @@ export const OPEN_STATUSES = ['lobby', 'veto'];
export const RUNNING_STATUSES = ['server_setup', 'live'];
export const CLOSED_STATUSES = ['finished', 'cancelled'];
const ROLE_RANK = { public: 0, spectator: 1, player: 2, captain: 3, organizer: 4 };
function cleanName(value, field, max = 32) {
const name = String(value ?? '').replace(/\s+/g, ' ').trim();
@@ -91,26 +90,49 @@ export class MatchService extends EventEmitter {
});
}
/** Ermittelt die Rolle eines Tokens für ein Match. Unbekannte Tokens ergeben "public". */
resolveViewer(matchId, token) {
/**
* Ermittelt, wer der Aufrufer in einem Match ist. Ein Browser darf bis zu zwei Zugänge mitschicken
* (z. B. Organisator-Token und Spieler-Token), damit der Organisator selbst mitspielen kann.
* Unbekannte Tokens werden ignoriert; ohne gültigen Zugang ergibt sich "public".
*/
resolveViewer(matchId, tokens) {
const match = this.getMatch(matchId);
const viewer = { matchId, role: 'public', playerId: null, team: null };
if (!token) return viewer;
if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' };
const player = this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
.get(matchId, hashToken(token));
if (player) {
return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team };
const viewer = {
matchId, role: 'public', isOrganizer: false, isSpectator: false, isCaptain: false, playerId: null, team: null,
};
const list = (Array.isArray(tokens) ? tokens : [tokens]).filter((t) => typeof t === 'string' && t).slice(0, 2);
for (const token of list) {
if (safeEqual(hashToken(token), match.organizer_token_hash)) {
viewer.isOrganizer = true;
continue;
}
const player = this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
.get(matchId, hashToken(token));
if (player) {
// Höchstens ein Spieler pro Aufrufer – ein zweiter Spieler-Token wird ignoriert.
if (!viewer.playerId) Object.assign(viewer, { playerId: player.id, team: player.team, isCaptain: !!player.is_captain });
continue;
}
if (safeEqual(normalizeCode(token), match.code_spectator)) viewer.isSpectator = true;
}
if (safeEqual(normalizeCode(token), match.code_spectator)) return { ...viewer, role: 'spectator' };
viewer.role = viewer.isOrganizer ? 'organizer'
: viewer.isCaptain ? 'captain'
: viewer.playerId ? 'player'
: viewer.isSpectator ? 'spectator' : 'public';
return viewer;
}
require(viewer, minimum) {
if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) {
require(viewer, need) {
const ok = {
organizer: viewer.isOrganizer,
captain: viewer.isCaptain,
player: !!viewer.playerId,
spectator: viewer.role !== 'public',
}[need];
if (!ok) {
const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' };
throw new HttpError(403, `Diese Aktion ist nur für ${labels[minimum]} erlaubt`, 'forbidden');
throw new HttpError(403, `Diese Aktion ist nur für ${labels[need]} erlaubt`, 'forbidden');
}
}
@@ -259,7 +281,7 @@ export class MatchService extends EventEmitter {
.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1')
.get(playerId, viewer.matchId);
if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found');
const allowed = viewer.role === 'organizer' || (viewer.role === 'captain' && viewer.team === target.team);
const allowed = viewer.isOrganizer || (viewer.isCaptain && viewer.team === target.team);
if (!allowed) throw new HttpError(403, 'Nur der Organisator oder der Captain des Teams kann den Captain ändern', 'forbidden');
this.db.prepare('UPDATE players SET is_captain = 0 WHERE match_id = ? AND team = ?').run(viewer.matchId, target.team);
this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(target.id);
@@ -529,7 +551,10 @@ export class MatchService extends EventEmitter {
updatedAt: match.updated_at,
vetoStart: match.veto_start,
currentMapNumber: match.current_map_number,
viewer: { role: viewer.role, playerId: viewer.playerId, team: viewer.team },
viewer: {
role: viewer.role, playerId: viewer.playerId, team: viewer.team,
isOrganizer: viewer.isOrganizer, isCaptain: viewer.isCaptain, isSpectator: viewer.isSpectator,
},
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
requireFullTeams: this.config.requireFullTeams,
maps: pool.maps,
@@ -561,7 +586,7 @@ export class MatchService extends EventEmitter {
.all(matchId),
};
if (viewer.role === 'organizer') {
if (viewer.isOrganizer) {
const base = this.config.publicUrl;
snapshot.organizer = {
codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) },
@@ -591,8 +616,8 @@ export class MatchService extends EventEmitter {
const configured = row.status === 'configured';
if (!configured || viewer.role === 'public') return view;
const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role);
const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role);
const isTeam = viewer.isOrganizer || !!viewer.playerId;
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
if (isTeam) {
view.connect = {
host: row.host,
+12 -6
View File
@@ -17,12 +17,17 @@ export function attachRealtime(httpServer, { service, config }) {
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
const sendState = (socket) => {
const { matchId, token } = socket.data;
const { matchId, token, playerToken } = socket.data;
try {
const viewer = service.resolveViewer(matchId, token);
const viewer = service.resolveViewer(matchId, [token, playerToken]);
// Rolle kann sich geändert haben (z. B. beigetreten, entfernt) – Anwesenheit vor dem Snapshot nachziehen
// und die anderen informieren, damit der Online-Status sofort stimmt.
const map = presence.get(matchId);
const key = viewer.playerId ?? `socket:${socket.id}`;
const changed = map && map.get(socket.id) !== key;
map?.set(socket.id, key);
socket.emit('match:state', service.snapshot(matchId, viewer));
// Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen.
presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`);
if (changed) broadcastMatch(matchId);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code ?? 'error' });
}
@@ -56,10 +61,11 @@ export function attachRealtime(httpServer, { service, config }) {
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('match:subscribe', ({ matchId, token } = {}) => {
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
socket.data = { matchId, token: typeof token === 'string' ? token : null };
const str = (v) => (typeof v === 'string' ? v : null);
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
try {
service.getMatch(matchId);
} catch (err) {
+37 -1
View File
@@ -19,12 +19,13 @@ async function setup(overrides = {}) {
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${app.server.address().port}/api`;
const call = async (method, path, { token, body } = {}) => {
const call = async (method, path, { token, playerToken, body } = {}) => {
const res = await fetch(base + path, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...(playerToken ? { 'x-player-token': playerToken } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
@@ -217,6 +218,41 @@ test('ungenutzte Server werden nach der Frist heruntergefahren', async () => {
}
});
test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async () => {
const { call, close } = await setup({ requireFullTeams: false });
try {
const { id, organizerToken: org } = (await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' },
})).body;
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
const me = (await call('POST', `/matches/${id}/join`, { body: { code: codes.A, nickname: 'orga' } })).body;
const b1 = (await call('POST', `/matches/${id}/join`, { body: { code: codes.B, nickname: 'b1' } })).body;
const both = { token: org, playerToken: me.playerToken };
const view = (await call('GET', `/matches/${id}`, both)).body;
assert.equal(view.viewer.role, 'organizer');
assert.equal(view.viewer.isOrganizer, true);
assert.equal(view.viewer.isCaptain, true);
assert.equal(view.viewer.team, 'A');
assert.ok(view.organizer, 'Organisator-Daten bleiben sichtbar');
assert.equal((await call('POST', `/matches/${id}/ready`, { ...both, body: { ready: true } })).status, 200);
await call('POST', `/matches/${id}/ready`, { token: b1.playerToken, body: { ready: true } });
assert.equal((await call('POST', `/matches/${id}/veto/start`, both)).status, 200);
// Nur mit Organisator-Zugang ist er kein Captain und darf nicht bannen
const ban = { stepIndex: 0, map: 'de_inferno' };
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: org, body: ban })).status, 403);
assert.equal((await call('POST', `/matches/${id}/veto/action`, { ...both, body: ban })).status, 200);
// Ein fremder Spieler-Token macht niemanden zum Organisator
const fake = (await call('GET', `/matches/${id}`, { token: b1.playerToken, playerToken: me.playerToken })).body;
assert.equal(fake.viewer.isOrganizer, false);
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {