diff --git a/deploy/ttrpg.compose.yml b/deploy/ttrpg.compose.yml index 0710707..aa55066 100644 --- a/deploy/ttrpg.compose.yml +++ b/deploy/ttrpg.compose.yml @@ -17,10 +17,20 @@ services: - ALLOWED_ORIGINS=https://ttrpg.briggen.dev # comma-separated usernames that can see the admin panel (set to your account) - ADMIN_USERS=nilsb + # default per-user cloud storage cap (bytes); admins override per user in the dashboard + - DEFAULT_QUOTA_BYTES=31457280 # 30 MB + # hard ceiling on accounts so open registration can't fill the disk + - MAX_USERS=500 volumes: - ttrpg-data:/data security_opt: - no-new-privileges:true + # Hard caps so a runaway session can't starve the host or the shared Traefik + # stack — the blast radius stays this container, then `restart: unless-stopped`. + mem_limit: 512m + memswap_limit: 512m + pids_limit: 200 + cpus: 1.0 labels: - "traefik.enable=true" - "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)" diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index d098127..6af86c2 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -54,4 +54,22 @@ describe('AccountStore', () => { expect(await adminStore.setQuota('ghost', 1)).toBe(false); expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000); }); + + it('quotaFor uses the override when set, else the default', async () => { + const r = await store.register('quotaUser', 'password123'); + if (!r.ok) throw new Error('register failed'); + const u = (await store.userByToken(r.token))!; + const def = store.quotaFor(u); // instance default + expect(def).toBeGreaterThan(0); + await store.setQuota('quotaUser', 99); + expect(store.quotaFor((await store.userByToken(r.token))!)).toBe(99); + }); + + it('refuses registration past the max-users cap', async () => { + const capped = new AccountStore(dir, undefined, [], 1); + expect((await capped.register('first', 'password123')).ok).toBe(true); + const second = await capped.register('second', 'password123'); + expect(second.ok).toBe(false); + expect((second as { code: string }).code).toBe('closed'); + }); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index 9653c8b..c774a20 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -1,7 +1,12 @@ import crypto from 'node:crypto'; +import { promisify } from 'node:util'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +// Async scrypt so password hashing runs on the threadpool, NOT the single event +// loop — a burst of logins can't freeze the whole server (scryptSync did). +const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise; + /** * Lean file-backed accounts + cloud-backup store. No external DB — a single * users.json plus one blob file per user under DATA_DIR. Passwords are scrypt- @@ -23,9 +28,11 @@ interface User { const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; const MAX_TOKENS = 10; +/** Default per-user cloud storage cap when no admin override is set. */ +export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024; function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); } -function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); } +async function hashPassword(password: string, salt: string): Promise { return (await scryptAsync(password, salt, 64)).toString('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); @@ -37,15 +44,22 @@ export interface AuthError { ok: false; code: string; message: string } export class AccountStore { private users = new Map(); // key: lowercased username private byId = new Map(); + private byToken = new Map(); // sha256(token) → user, for O(1) auth lookups private queue: Promise = Promise.resolve(); private loaded = false; - constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {} + constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {} private usersFile() { return path.join(this.dir, 'users.json'); } private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); } isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); } + /** This user's storage limit: their admin override, else the instance default. */ + quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; } + + /** Whether a new account can be created (registration is open under the cap). */ + atCapacity(): boolean { return this.users.size >= this.maxUsers; } + async listUsers(): Promise> { await this.load(); return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) })); @@ -72,8 +86,15 @@ export class AccountStore { try { const raw = await fs.readFile(this.usersFile(), 'utf8'); const arr = JSON.parse(raw) as User[]; - for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); } - } catch { /* no file yet */ } + for (const u of arr) { + this.users.set(u.username.toLowerCase(), u); + this.byId.set(u.id, u); + for (const h of u.tokens) this.byToken.set(h, u); + } + } catch (e) { + // ENOENT on first boot is expected; anything else is a real problem worth seeing. + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e); + } } private persist(): Promise { @@ -82,14 +103,19 @@ export class AccountStore { const tmp = `${this.usersFile()}.tmp`; await fs.writeFile(tmp, JSON.stringify([...this.users.values()])); await fs.rename(tmp, this.usersFile()); - }).catch(() => {}); + }).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); }); return this.queue as Promise; } private issueToken(u: User): string { const token = crypto.randomBytes(32).toString('base64url'); - u.tokens.push(sha256(token)); - if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS); + const h = sha256(token); + u.tokens.push(h); + this.byToken.set(h, u); + if (u.tokens.length > MAX_TOKENS) { + for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale); + u.tokens = u.tokens.slice(-MAX_TOKENS); + } u.updatedAt = this.now(); return token; } @@ -99,8 +125,9 @@ export class AccountStore { if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' }; if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' }; if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' }; + if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' }; const salt = crypto.randomBytes(16).toString('hex'); - const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; + const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; const token = this.issueToken(u); this.users.set(username.toLowerCase(), u); this.byId.set(u.id, u); @@ -111,7 +138,11 @@ export class AccountStore { async login(username: string, password: string): Promise { await this.load(); const u = this.users.get((username ?? '').toLowerCase()); - if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; + // Hash even when the user is unknown (against a throwaway salt) so the response + // time doesn't reveal whether a username exists. + const salt = u?.salt ?? 'absent'; + const candidate = await hashPassword(password ?? '', salt); + if (!u || !timingEqual(u.hash, candidate)) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; const token = this.issueToken(u); await this.persist(); return { ok: true, token, username: u.username }; @@ -120,15 +151,16 @@ export class AccountStore { async userByToken(token: string | undefined): Promise { if (!token) return null; await this.load(); - const h = sha256(token); - for (const u of this.users.values()) if (u.tokens.includes(h)) return u; - return null; + return this.byToken.get(sha256(token)) ?? null; } async logout(token: string): Promise { - const u = await this.userByToken(token); + await this.load(); + const h = sha256(token); + const u = this.byToken.get(h); if (!u) return; - u.tokens = u.tokens.filter((t) => t !== sha256(token)); + u.tokens = u.tokens.filter((t) => t !== h); + this.byToken.delete(h); await this.persist(); } diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts index 6501b0d..49fd8d4 100644 --- a/server/src/campaigns.test.ts +++ b/server/src/campaigns.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { CloudStore } from './campaigns'; +import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; describe('CloudStore', () => { let dir: string; @@ -42,6 +42,19 @@ describe('CloudStore', () => { expect(await store.usageBytes('p1')).toBeGreaterThan(0); }); + it('rejects an oversized character and reports usage deltas', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + await store.joinByInvite('p1', c.inviteCode); + const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1); + expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull(); + expect(await store.characterBytes('big')).toBe(0); // nothing stored + + await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' }); + expect(store.ownsCharacter('p1', 'ch1')).toBe(true); + expect(store.ownsCharacter('p2', 'ch1')).toBe(false); + expect(await store.characterBytes('ch1')).toBe(Buffer.byteLength('{"hp":1}')); + }); + it('lets the char owner or campaign owner delete; persists across instances', async () => { const c = await store.createCampaign('gm1', 'C', '5e'); await store.joinByInvite('p1', c.inviteCode); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts index bc03e21..f6915fa 100644 --- a/server/src/campaigns.ts +++ b/server/src/campaigns.ts @@ -32,6 +32,9 @@ export interface CloudCharacter { } const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +/** A single published character can't exceed this (a sheet with an embedded portrait + * is well under 2 MB); a hard stop before per-user quota even comes into play. */ +export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024; export class CloudStore { private campaigns = new Map(); @@ -50,7 +53,9 @@ export class CloudStore { const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] }; for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); } for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch); - } catch { /* no file yet */ } + } catch (e) { + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e); + } } private persist(): Promise { @@ -59,7 +64,7 @@ export class CloudStore { const tmp = `${this.file()}.tmp`; await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] })); await fs.rename(tmp, this.file()); - }).catch(() => {}); + }).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); }); return this.queue as Promise; } @@ -140,6 +145,7 @@ export class CloudStore { async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise { await this.load(); if (!this.isMember(campaignId, userId)) return null; + if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject const existing = this.characters.get(char.id); if (existing && existing.ownerUserId !== userId) return null; // only the owner may update const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() }; @@ -163,6 +169,18 @@ export class CloudStore { return true; } + /** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */ + async characterBytes(characterId: string): Promise { + await this.load(); + const ch = this.characters.get(characterId); + return ch ? Buffer.byteLength(ch.data) : 0; + } + + /** Whether the user already owns a published character with this id. */ + ownsCharacter(userId: string, characterId: string): boolean { + return this.characters.get(characterId)?.ownerUserId === userId; + } + /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */ async usageBytes(userId: string): Promise { await this.load(); diff --git a/server/src/index.ts b/server/src/index.ts index 012b988..eb8f70e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,15 +6,19 @@ import type { FastifyRequest } from 'fastify'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { RoomHub, type Sender } from './rooms'; import { AccountStore } from './accounts'; -import { CloudStore } from './campaigns'; +import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; const PORT = Number(process.env.PORT ?? 8787); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data')); const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS) -const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs) +const BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota) const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); +const MAX_USERS = Number(process.env.MAX_USERS) || Infinity; +// Concurrent WebSocket connections allowed from one client IP — generous for a +// household sharing a NAT, but a hard stop on socket-spam room/image floods. +const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20; const bearer = (req: FastifyRequest): string | undefined => { const h = req.headers.authorization; @@ -29,30 +33,49 @@ const RATE = { capacity: 80, refillMs: 10_000 }; const HEARTBEAT_MS = 30_000; export function buildServer() { - const app = Fastify({ bodyLimit: BODY_LIMIT }); + // trustProxy: behind Traefik the socket peer is the proxy, so without this every + // visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so + // req.ip is the real client. Only Traefik can reach the container, so this is safe. + const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true }); const hub = new RoomHub(); - const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS); + const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS); void accounts.load(); const cloud = new CloudStore(DATA_DIR); void cloud.load(); - const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); + const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000); app.addHook('onClose', async () => clearInterval(sweeper)); - // Simple per-IP throttle for the account/cloud API. + // Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket + // since each call does password hashing and is the target of credential-stuffing. const ipHits = new Map(); + const authHits = new Map(); + const AUTH_PATHS = new Set(['/api/register', '/api/login']); + const bump = (map: Map, ip: string, limit: number, windowMs: number): boolean => { + const now = Date.now(); + const e = map.get(ip); + if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; } + return ++e.n <= limit; + }; + const pruneRateBuckets = () => { + const now = Date.now(); + for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip); + for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip); + }; app.addHook('onRequest', async (req, reply) => { if (!req.url.startsWith('/api/')) return; - const now = Date.now(); - const e = ipHits.get(req.ip); - if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 }); - else if (++e.n > 60) await reply.code(429).send({ error: 'rate' }); + if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' }); + // Strip the querystring before matching the auth path. + const path0 = req.url.split('?')[0]!; + if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) { + return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' }); + } }); // ---- accounts + cloud backup sync ---- app.post('/api/register', async (req, reply) => { const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; const r = await accounts.register(String(username ?? ''), String(password ?? '')); - if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message }); + if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message }); return { token: r.token, username: r.username }; }); app.post('/api/login', async (req, reply) => { @@ -67,6 +90,11 @@ export function buildServer() { if (!u) return reply.code(401).send({ error: 'unauthorized' }); const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {}; if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); + // Quota: the backup blob plus the user's published characters must fit their limit. + const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id)); + if (projected > accounts.quotaFor(u)) { + return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) }); + } // Optimistic concurrency: if the caller names the version it last synced and // the cloud has since moved on (another device pushed), refuse — no silent // overwrite. The client then resolves (pull, or force-push). @@ -121,8 +149,15 @@ export function buildServer() { const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } }; if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' }); + if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' }); + // Quota: total usage after this upsert (replace the old copy's bytes with the new). + const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0); + const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta; + if (projected > accounts.quotaFor(u)) { + return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) }); + } const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data }); - if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' }); + if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' }); return { ok: true, updatedAt: r.updatedAt }; }); app.get('/api/campaigns/:id/characters', async (req, reply) => { @@ -141,7 +176,7 @@ export function buildServer() { app.get('/api/usage', async (req, reply) => { const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)); - return { bytes, admin: accounts.isAdmin(u.username) }; + return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) }; }); // ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ---- @@ -166,6 +201,9 @@ export function buildServer() { void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); + // Live WebSocket connections per client IP — a hard cap on socket-spam. + const wsPerIp = new Map(); + void app.register(async (instance) => { instance.get('/ws', { websocket: true }, (socket, req) => { // Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured. @@ -174,6 +212,11 @@ export function buildServer() { socket.close(1008, 'origin'); return; } + const ip = req.ip; + const live = wsPerIp.get(ip) ?? 0; + if (live >= MAX_WS_PER_IP) { socket.close(1013, 'too-many'); return; } + wsPerIp.set(ip, live + 1); + const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); }; const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } }; let tokens = RATE.capacity; const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); @@ -209,11 +252,11 @@ export function buildServer() { case 'setName': hub.setName(sender, m.name); break; } }); - socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); }); + socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); }); }); }); - app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() })); + app.get('/healthz', async () => ({ ok: true })); // Serve the built SPA with history fallback (so /play?room=... deep-links work). void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 194e993..f5bcbab 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -92,6 +92,31 @@ describe('RoomHub', () => { expect(hub.roomCount()).toBe(0); }); + it('caps the number of concurrent rooms', () => { + const hub = new RoomHub(() => 0, { maxRooms: 2 }); + const a = fake(); hub.host(a); + const b = fake(); hub.host(b); + expect(hub.roomCount()).toBe(2); + const c = fake(); hub.host(c); + expect(hub.roomCount()).toBe(2); // refused + expect(lastOf(c, 'error')).toMatchObject({ code: 'busy' }); + expect(lastOf(c, 'hosted')).toBeUndefined(); + }); + + it('caps per-room image count and total bytes', () => { + const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 }); + const gm = fake(); hub.host(gm); + const { gmSecret } = lastOf(gm, 'hosted') as Extract; + hub.image(gm, gmSecret, 'm1', 'x'.repeat(40)); + hub.image(gm, gmSecret, 'm2', 'x'.repeat(40)); // total 80 ≤ 100, count 2 ≤ 2 + expect(lastOf(gm, 'error')).toBeUndefined(); + hub.image(gm, gmSecret, 'm3', 'x'.repeat(10)); // 3rd distinct image → count cap + expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' }); + // overwriting an existing id is allowed, but not past the byte budget + hub.image(gm, gmSecret, 'm1', 'x'.repeat(90)); // 90 + 40 = 130 > 100 → rejected + expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' }); + }); + it('routes a seat claim → GM grant → player gets their sheet', () => { const hub = new RoomHub(); const { gm, player, secret } = hostAndJoin(hub); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 872f86c..4276e12 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -34,6 +34,8 @@ interface Room { players: Set; snapshot: Snapshot | null; images: Map; + /** running total of bytes in `images`, kept so the cap is O(1) to check */ + imageBytes: number; /** granted seats keyed by playerId */ seats: Map; /** seat requests awaiting GM approval (re-sent when the GM reconnects) */ @@ -43,6 +45,13 @@ interface Room { 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'); @@ -62,7 +71,10 @@ export class RoomHub { private rooms = new Map(); private byCode = new Map(); private conns = new Map(); - constructor(private now: () => number = () => Date.now()) {} + private limits: RoomLimits; + constructor(private now: () => number = () => Date.now(), limits: Partial = {}) { + this.limits = { ...DEFAULT_LIMITS, ...limits }; + } private mintJoinCode(): string { for (let attempt = 0; attempt < 50; attempt++) { @@ -90,13 +102,19 @@ export class RoomHub { } } } + // 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(), + gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0, seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(), }; this.rooms.set(roomId, room); @@ -148,7 +166,17 @@ export class RoomHub { 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 }); } diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx index 08e594d..d947263 100644 --- a/src/features/settings/CloudCampaigns.tsx +++ b/src/features/settings/CloudCampaigns.tsx @@ -21,7 +21,7 @@ export function CloudCampaigns() { const [targetCloud, setTargetCloud] = useState(''); const [viewing, setViewing] = useState(null); const [party, setParty] = useState([]); - const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null); + const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); const [admins, setAdmins] = useState([]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); @@ -122,11 +122,25 @@ export function CloudCampaigns() { - {usage &&

Your cloud storage: {fmtBytes(usage.bytes)}

} + {usage && (() => { + const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0; + const near = pct >= 90; + return ( +
+

+ Your cloud storage: {fmtBytes(usage.bytes)} of {fmtBytes(usage.quota)} ({pct}%) + {near && ' — almost full; trim old data or ask the admin to raise your quota.'} +

+
+
+
+
+ ); + })()} {usage?.admin && (
-

Admin · users & storage

+

Admin · users & storage (you are signed in as the admin)

diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 2f89cea..506d2a8 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -53,8 +53,8 @@ export async function cloudUsageBytes(): Promise { } export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number } -export function getUsage(): Promise<{ bytes: number; admin: boolean }> { - return req<{ bytes: number; admin: boolean }>('/usage'); +export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> { + return req<{ bytes: number; quota: number; admin: boolean }>('/usage'); } export function adminListUsers(): Promise<{ users: AdminUserRow[] }> { return req<{ users: AdminUserRow[] }>('/admin/users'); diff --git a/src/lib/cloud/client.ts b/src/lib/cloud/client.ts index d8a8ad3..9015059 100644 --- a/src/lib/cloud/client.ts +++ b/src/lib/cloud/client.ts @@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; } + if (res.status === 413) throw new CloudError('Cloud storage limit reached — trim old data or ask the admin to raise your quota.'); + if (res.status === 429) throw new CloudError('Too many requests — please wait a moment and try again.'); if (!res.ok) throw new CloudError('Upload failed.'); const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt); diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 47ec628..4239a3a 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -130,8 +130,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }), z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional(), playerId: z.string().max(64).optional() }), z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }), - z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }), - z.object({ t: z.literal('requestImage'), id: z.string() }), + z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string().max(128), dataUrl: z.string().max(10_000_000) }), + z.object({ t: z.literal('requestImage'), id: z.string().max(128) }), // two-way play (player → server) z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }), z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
UserUsedQuota (GB, 0=default)