0892b8f19c
Audit-driven; goal: one user can't exhaust the box or starve the shared Traefik stack. Resource containment: - compose: mem_limit 512m, memswap_limit, pids_limit 200, cpus 1.0 (blast radius = container) - Enforce per-user cloud quota (default 30MB, admin override) on /api/save + /api/characters → 413 - Cap published-character size (2MB); cap rooms (200), images/room (40), image bytes/room (40MB) - Cap WS image dataUrl length in the wire schema; per-IP WS connection cap (20) - MAX_USERS registration ceiling → 503 when full Availability + correctness: - Async crypto.scrypt (was scryptSync) so password hashing can't freeze the event loop; constant-time on unknown users to avoid username probing - trustProxy so req.ip is the real client behind Traefik — rate limits are per-IP, not global - Tighter auth-endpoint bucket (10/min) on /register + /login; prune stale rate buckets - O(1) token->user index; log persist() failures instead of swallowing them - Trim /healthz info; surface quota in /api/usage + the admin dashboard storage bar - Client surfaces 413 (quota) / 429 (rate) clearly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import type { ServerMessage, Snapshot, PartialCharacterDiff, PrivateHandout } from '@/lib/sync/messages';
|
|
import type { Character } from '@/lib/schemas/character';
|
|
|
|
export interface Sender {
|
|
send: (msg: ServerMessage) => void;
|
|
}
|
|
|
|
interface Seat {
|
|
sender: Sender;
|
|
characterId: string;
|
|
name: string;
|
|
/** the granted sheet, re-sent on reconnect so the player regains control seamlessly */
|
|
character: Character;
|
|
/** set when the holder's socket drops; the seat survives a short grace window */
|
|
disconnectedAt?: number;
|
|
}
|
|
|
|
/** How long a seat survives after its player disconnects, so a reconnect can reclaim it. */
|
|
const SEAT_GRACE_MS = 5 * 60 * 1000;
|
|
|
|
interface SeatRequest {
|
|
playerId: string;
|
|
characterId: string;
|
|
offlineSnapshot?: Character;
|
|
}
|
|
|
|
interface Room {
|
|
roomId: string;
|
|
joinCode: string;
|
|
gmSecretHash: string;
|
|
passwordHash: string | null;
|
|
gm: Sender | null;
|
|
players: Set<Sender>;
|
|
snapshot: Snapshot | null;
|
|
images: Map<string, string>;
|
|
/** running total of bytes in `images`, kept so the cap is O(1) to check */
|
|
imageBytes: number;
|
|
/** granted seats keyed by playerId */
|
|
seats: Map<string, Seat>;
|
|
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
|
|
pendingSeatRequests: SeatRequest[];
|
|
lastActivity: number;
|
|
}
|
|
|
|
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
|
|
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
|
|
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
|
|
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number }
|
|
const DEFAULT_LIMITS: RoomLimits = {
|
|
maxRooms: Number(process.env.MAX_ROOMS) || 200,
|
|
maxImagesPerRoom: 40,
|
|
maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room
|
|
};
|
|
|
|
function sha256(s: string): string {
|
|
return crypto.createHash('sha256').update(s).digest('hex');
|
|
}
|
|
function timingEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a), bb = Buffer.from(b);
|
|
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
/**
|
|
* In-memory, GM-authoritative room registry. No persistence. The GM is the only
|
|
* writer of shared state; players are read-only EXCEPT for their own seat (HP,
|
|
* slots, conditions, rolls), which the hub forwards to the GM to persist.
|
|
* `now` is injectable for tests.
|
|
*/
|
|
export class RoomHub {
|
|
private rooms = new Map<string, Room>();
|
|
private byCode = new Map<string, string>();
|
|
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
|
|
private limits: RoomLimits;
|
|
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
|
|
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
}
|
|
|
|
private mintJoinCode(): string {
|
|
for (let attempt = 0; attempt < 50; attempt++) {
|
|
let code = '';
|
|
const bytes = crypto.randomBytes(6);
|
|
for (let i = 0; i < 6; i++) code += JOIN_ALPHABET[bytes[i]! % JOIN_ALPHABET.length];
|
|
if (!this.byCode.has(code)) return code;
|
|
}
|
|
return crypto.randomBytes(8).toString('hex').toUpperCase().slice(0, 6);
|
|
}
|
|
|
|
host(socket: Sender, password?: string, resume?: string): void {
|
|
// Resume an existing room if the GM presents its secret (seamless reconnect).
|
|
if (resume) {
|
|
const hash = sha256(resume);
|
|
for (const room of this.rooms.values()) {
|
|
if (timingEqual(room.gmSecretHash, hash)) {
|
|
room.gm = socket;
|
|
room.lastActivity = this.now();
|
|
this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' });
|
|
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
|
|
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
|
|
this.sendRoster(room);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out;
|
|
// a sweep runs first to reclaim any that just expired before we refuse.
|
|
if (this.rooms.size >= this.limits.maxRooms) {
|
|
this.sweep();
|
|
if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; }
|
|
}
|
|
const roomId = crypto.randomUUID();
|
|
const joinCode = this.mintJoinCode();
|
|
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
|
const room: Room = {
|
|
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
|
passwordHash: password ? sha256(password) : null,
|
|
gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0,
|
|
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
|
|
};
|
|
this.rooms.set(roomId, room);
|
|
this.byCode.set(joinCode, roomId);
|
|
this.conns.set(socket, { roomId, role: 'gm', playerId: 'gm' });
|
|
socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
|
|
}
|
|
|
|
join(socket: Sender, joinCode: string, password?: string, clientPlayerId?: string): void {
|
|
const roomId = this.byCode.get(joinCode.trim().toUpperCase());
|
|
const room = roomId ? this.rooms.get(roomId) : undefined;
|
|
if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; }
|
|
if (room.passwordHash && (!password || !timingEqual(room.passwordHash, sha256(password)))) {
|
|
socket.send({ t: 'error', code: 'bad-password', message: 'Wrong session password.' });
|
|
return;
|
|
}
|
|
room.players.add(socket);
|
|
room.lastActivity = this.now();
|
|
// Reuse the client's stable id (so a transient drop keeps the seat) ONLY when it
|
|
// isn't already live on another connection — preventing a second tab/peer from
|
|
// seizing an active seat. The id is a client-generated UUID kept in localStorage.
|
|
let playerId: string = crypto.randomUUID();
|
|
if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) {
|
|
const liveElsewhere = [...this.conns.values()].some((c) => c.roomId === room.roomId && c.playerId === clientPlayerId);
|
|
if (!liveElsewhere) playerId = clientPlayerId;
|
|
}
|
|
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId });
|
|
socket.send({ t: 'joined', roomId: room.roomId });
|
|
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
|
|
// Reconnect: if this player still owns a seat, re-bind it to the new socket and
|
|
// re-grant the sheet so their HP/condition/roll messages are accepted again.
|
|
const seat = room.seats.get(playerId);
|
|
if (seat) {
|
|
seat.sender = socket;
|
|
delete seat.disconnectedAt;
|
|
socket.send({ t: 'seatGranted', character: seat.character });
|
|
}
|
|
this.sendRoster(room);
|
|
}
|
|
|
|
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
|
|
const room = this.gmRoom(socket, gmSecret);
|
|
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
|
room.snapshot = snapshot;
|
|
room.lastActivity = this.now();
|
|
for (const p of room.players) p.send({ t: 'snapshot', snapshot });
|
|
}
|
|
|
|
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
|
const room = this.gmRoom(socket, gmSecret);
|
|
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
|
// Bound per-room image memory: cap the count of distinct images and the total bytes.
|
|
const size = Buffer.byteLength(dataUrl);
|
|
const prev = room.images.get(id);
|
|
const prevSize = prev ? Buffer.byteLength(prev) : 0;
|
|
const isNew = prev === undefined;
|
|
if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) {
|
|
socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' });
|
|
return;
|
|
}
|
|
room.images.set(id, dataUrl);
|
|
room.imageBytes += size - prevSize;
|
|
room.lastActivity = this.now();
|
|
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
|
}
|
|
|
|
requestImage(socket: Sender, id: string): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
const dataUrl = room?.images.get(id);
|
|
if (dataUrl) socket.send({ t: 'mapImage', id, dataUrl });
|
|
}
|
|
|
|
/** Player asks to control a character; the request is forwarded to the GM. */
|
|
claimSeat(socket: Sender, characterId: string, offlineSnapshot?: Character): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
if (!conn || conn.role !== 'player' || !room) return;
|
|
room.lastActivity = this.now();
|
|
const req: SeatRequest = { playerId: conn.playerId, characterId, ...(offlineSnapshot ? { offlineSnapshot } : {}) };
|
|
room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== conn.playerId);
|
|
room.pendingSeatRequests.push(req);
|
|
room.gm?.send({ t: 'seatRequest', ...req });
|
|
}
|
|
|
|
/** GM approves a seat request and hands the player their authoritative sheet. */
|
|
seatGrant(socket: Sender, gmSecret: string, targetPlayerId: string, character: Character): void {
|
|
const room = this.gmRoom(socket, gmSecret);
|
|
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
|
room.lastActivity = this.now();
|
|
room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== targetPlayerId);
|
|
let target: Sender | undefined;
|
|
for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } }
|
|
if (!target) return;
|
|
room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name, character });
|
|
target.send({ t: 'seatGranted', character });
|
|
this.sendRoster(room);
|
|
}
|
|
|
|
/** GM sends a private handout/note/image to one player; non-recipients never receive it. */
|
|
privateState(socket: Sender, gmSecret: string, targetPlayerId: string, handout: PrivateHandout | null): void {
|
|
const room = this.gmRoom(socket, gmSecret);
|
|
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
|
room.lastActivity = this.now();
|
|
for (const [s, c] of this.conns) {
|
|
if (c.roomId === room.roomId && c.playerId === targetPlayerId) s.send({ t: 'privateHandout', handout });
|
|
}
|
|
}
|
|
|
|
/** Tell everyone (GM + players) the current roster: the GM (if present) + players. */
|
|
private sendRoster(room: Room): void {
|
|
const players: { playerId: string; name: string; character?: string }[] = [];
|
|
if (room.gm) players.push({ playerId: 'gm', name: 'GM' });
|
|
const seen = new Set<string>();
|
|
for (const s of room.players) {
|
|
const conn = this.conns.get(s);
|
|
if (!conn || seen.has(conn.playerId)) continue;
|
|
seen.add(conn.playerId);
|
|
const seatName = room.seats.get(conn.playerId)?.name;
|
|
const name = conn.name ?? seatName ?? `Player ${conn.playerId.slice(0, 4)}`;
|
|
players.push({ playerId: conn.playerId, name, ...(conn.name && seatName ? { character: seatName } : {}) });
|
|
}
|
|
const msg = { t: 'roster', players } as const;
|
|
room.gm?.send(msg);
|
|
for (const p of room.players) p.send(msg);
|
|
}
|
|
|
|
/** A player sets their display name (roster + chat). */
|
|
setName(socket: Sender, name: string): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
if (!conn || conn.role !== 'player' || !room) return;
|
|
const trimmed = name.trim().slice(0, 40);
|
|
if (trimmed) conn.name = trimmed; else delete conn.name;
|
|
room.lastActivity = this.now();
|
|
this.sendRoster(room);
|
|
}
|
|
|
|
/** A seated player edits their own sheet; the diff is forwarded to the GM only. */
|
|
playerPatch(socket: Sender, characterId: string, diff: PartialCharacterDiff): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
if (!conn || conn.role !== 'player' || !room) return;
|
|
const seat = room.seats.get(conn.playerId);
|
|
if (!seat || seat.characterId !== characterId) return; // must hold the seat for this character
|
|
room.lastActivity = this.now();
|
|
room.gm?.send({ t: 'playerPatched', characterId, diff });
|
|
}
|
|
|
|
/** A seated player rolls dice; broadcast to the GM and the rest of the table. */
|
|
playerRoll(socket: Sender, _characterId: string, label: string, expression: string, total: number, breakdown: string): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
if (!conn || conn.role !== 'player' || !room) return;
|
|
const seat = room.seats.get(conn.playerId);
|
|
if (!seat) return; // only seated players broadcast rolls
|
|
room.lastActivity = this.now();
|
|
const msg = { t: 'rollBroadcast', playerName: seat.name, label, expression, total, breakdown } as const;
|
|
room.gm?.send(msg);
|
|
for (const p of room.players) if (p !== socket) p.send(msg);
|
|
}
|
|
|
|
/** The GM broadcasts one of their own (public) rolls to the players. */
|
|
gmRoll(socket: Sender, gmSecret: string, label: string, expression: string, total: number, breakdown: string): void {
|
|
const room = this.gmRoom(socket, gmSecret);
|
|
if (!room) return;
|
|
room.lastActivity = this.now();
|
|
const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const;
|
|
for (const p of room.players) p.send(msg); // players only; the GM already sees it locally
|
|
}
|
|
|
|
/** Table chat (to all) or a whisper. GM `to` = target player; player `to` set = whisper to GM. */
|
|
chat(socket: Sender, body: string, to?: string): void {
|
|
const conn = this.conns.get(socket);
|
|
const room = conn ? this.rooms.get(conn.roomId) : undefined;
|
|
if (!conn || !room) return;
|
|
room.lastActivity = this.now();
|
|
if (conn.role === 'gm') {
|
|
if (to) {
|
|
const msg = { t: 'chat', from: 'GM', body, scope: 'whisper' } as const;
|
|
for (const [s, c] of this.conns) if (c.roomId === room.roomId && c.playerId === to) s.send(msg);
|
|
} else {
|
|
const msg = { t: 'chat', from: 'GM', body, scope: 'table' } as const;
|
|
for (const p of room.players) p.send(msg);
|
|
}
|
|
} else {
|
|
const from = conn.name ?? room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`;
|
|
if (to) {
|
|
room.gm?.send({ t: 'chat', from, body, scope: 'whisper' });
|
|
} else {
|
|
const msg = { t: 'chat', from, body, scope: 'table' } as const;
|
|
room.gm?.send(msg);
|
|
for (const p of room.players) if (p !== socket) p.send(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnect(socket: Sender): void {
|
|
const conn = this.conns.get(socket);
|
|
if (!conn) return;
|
|
const room = this.rooms.get(conn.roomId);
|
|
if (room) {
|
|
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
|
|
room.players.delete(socket);
|
|
// Don't drop the seat on disconnect — start its grace timer so a reconnect (same
|
|
// stable playerId) can reclaim it. sweep() evicts seats that stay gone too long.
|
|
for (const [, seat] of room.seats) if (seat.sender === socket) seat.disconnectedAt = this.now();
|
|
// Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it).
|
|
this.sendRoster(room);
|
|
}
|
|
this.conns.delete(socket);
|
|
}
|
|
|
|
/** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */
|
|
sweep(): void {
|
|
const now = this.now();
|
|
const cutoff = now - ROOM_TTL_MS;
|
|
for (const [id, room] of this.rooms) {
|
|
if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); continue; }
|
|
let dropped = false;
|
|
for (const [pid, seat] of room.seats) {
|
|
if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) {
|
|
room.seats.delete(pid);
|
|
dropped = true;
|
|
}
|
|
}
|
|
if (dropped) this.sendRoster(room);
|
|
}
|
|
}
|
|
|
|
roomCount(): number { return this.rooms.size; }
|
|
|
|
private gmRoom(socket: Sender, gmSecret: string): Room | null {
|
|
const conn = this.conns.get(socket);
|
|
if (!conn || conn.role !== 'gm') return null;
|
|
const room = this.rooms.get(conn.roomId);
|
|
if (!room || room.gm !== socket || !timingEqual(room.gmSecretHash, sha256(gmSecret))) return null;
|
|
return room;
|
|
}
|
|
}
|