Backend security hardening for a small public server
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>
This commit is contained in:
@@ -17,10 +17,20 @@ services:
|
|||||||
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
|
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
|
||||||
# comma-separated usernames that can see the admin panel (set to your account)
|
# comma-separated usernames that can see the admin panel (set to your account)
|
||||||
- ADMIN_USERS=nilsb
|
- 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:
|
volumes:
|
||||||
- ttrpg-data:/data
|
- ttrpg-data:/data
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- 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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
|
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
|
||||||
|
|||||||
@@ -54,4 +54,22 @@ describe('AccountStore', () => {
|
|||||||
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
|
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
|
||||||
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-14
@@ -1,7 +1,12 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
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<Buffer>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lean file-backed accounts + cloud-backup store. No external DB — a single
|
* 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-
|
* 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 USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
|
||||||
const MAX_TOKENS = 10;
|
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 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<string> { return (await scryptAsync(password, salt, 64)).toString('hex'); }
|
||||||
function timingEqual(a: string, b: string): boolean {
|
function timingEqual(a: string, b: string): boolean {
|
||||||
const ab = Buffer.from(a), bb = Buffer.from(b);
|
const ab = Buffer.from(a), bb = Buffer.from(b);
|
||||||
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
|
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 {
|
export class AccountStore {
|
||||||
private users = new Map<string, User>(); // key: lowercased username
|
private users = new Map<string, User>(); // key: lowercased username
|
||||||
private byId = new Map<string, User>();
|
private byId = new Map<string, User>();
|
||||||
|
private byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
private loaded = false;
|
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 usersFile() { return path.join(this.dir, 'users.json'); }
|
||||||
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
|
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
|
||||||
|
|
||||||
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
|
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<Array<{ id: string; username: string; quotaBytes?: number }>> {
|
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
|
||||||
await this.load();
|
await this.load();
|
||||||
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
|
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 {
|
try {
|
||||||
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
||||||
const arr = JSON.parse(raw) as User[];
|
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); }
|
for (const u of arr) {
|
||||||
} catch { /* no file yet */ }
|
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<void> {
|
private persist(): Promise<void> {
|
||||||
@@ -82,14 +103,19 @@ export class AccountStore {
|
|||||||
const tmp = `${this.usersFile()}.tmp`;
|
const tmp = `${this.usersFile()}.tmp`;
|
||||||
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
|
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
|
||||||
await fs.rename(tmp, this.usersFile());
|
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<void>;
|
return this.queue as Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private issueToken(u: User): string {
|
private issueToken(u: User): string {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
u.tokens.push(sha256(token));
|
const h = sha256(token);
|
||||||
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
|
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();
|
u.updatedAt = this.now();
|
||||||
return token;
|
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 (!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 (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.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 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);
|
const token = this.issueToken(u);
|
||||||
this.users.set(username.toLowerCase(), u);
|
this.users.set(username.toLowerCase(), u);
|
||||||
this.byId.set(u.id, u);
|
this.byId.set(u.id, u);
|
||||||
@@ -111,7 +138,11 @@ export class AccountStore {
|
|||||||
async login(username: string, password: string): Promise<AuthResult | AuthError> {
|
async login(username: string, password: string): Promise<AuthResult | AuthError> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const u = this.users.get((username ?? '').toLowerCase());
|
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);
|
const token = this.issueToken(u);
|
||||||
await this.persist();
|
await this.persist();
|
||||||
return { ok: true, token, username: u.username };
|
return { ok: true, token, username: u.username };
|
||||||
@@ -120,15 +151,16 @@ export class AccountStore {
|
|||||||
async userByToken(token: string | undefined): Promise<User | null> {
|
async userByToken(token: string | undefined): Promise<User | null> {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
await this.load();
|
await this.load();
|
||||||
const h = sha256(token);
|
return this.byToken.get(sha256(token)) ?? null;
|
||||||
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(token: string): Promise<void> {
|
async logout(token: string): Promise<void> {
|
||||||
const u = await this.userByToken(token);
|
await this.load();
|
||||||
|
const h = sha256(token);
|
||||||
|
const u = this.byToken.get(h);
|
||||||
if (!u) return;
|
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();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { CloudStore } from './campaigns';
|
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
|
||||||
|
|
||||||
describe('CloudStore', () => {
|
describe('CloudStore', () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
@@ -42,6 +42,19 @@ describe('CloudStore', () => {
|
|||||||
expect(await store.usageBytes('p1')).toBeGreaterThan(0);
|
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 () => {
|
it('lets the char owner or campaign owner delete; persists across instances', async () => {
|
||||||
const c = await store.createCampaign('gm1', 'C', '5e');
|
const c = await store.createCampaign('gm1', 'C', '5e');
|
||||||
await store.joinByInvite('p1', c.inviteCode);
|
await store.joinByInvite('p1', c.inviteCode);
|
||||||
|
|||||||
+20
-2
@@ -32,6 +32,9 @@ export interface CloudCharacter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
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 {
|
export class CloudStore {
|
||||||
private campaigns = new Map<string, CloudCampaign>();
|
private campaigns = new Map<string, CloudCampaign>();
|
||||||
@@ -50,7 +53,9 @@ export class CloudStore {
|
|||||||
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
|
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 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);
|
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<void> {
|
private persist(): Promise<void> {
|
||||||
@@ -59,7 +64,7 @@ export class CloudStore {
|
|||||||
const tmp = `${this.file()}.tmp`;
|
const tmp = `${this.file()}.tmp`;
|
||||||
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
|
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
|
||||||
await fs.rename(tmp, this.file());
|
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<void>;
|
return this.queue as Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +145,7 @@ export class CloudStore {
|
|||||||
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
|
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
|
||||||
await this.load();
|
await this.load();
|
||||||
if (!this.isMember(campaignId, userId)) return null;
|
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);
|
const existing = this.characters.get(char.id);
|
||||||
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
|
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() };
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */
|
||||||
|
async characterBytes(characterId: string): Promise<number> {
|
||||||
|
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. */
|
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
|
||||||
async usageBytes(userId: string): Promise<number> {
|
async usageBytes(userId: string): Promise<number> {
|
||||||
await this.load();
|
await this.load();
|
||||||
|
|||||||
+58
-15
@@ -6,15 +6,19 @@ import type { FastifyRequest } from 'fastify';
|
|||||||
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
||||||
import { RoomHub, type Sender } from './rooms';
|
import { RoomHub, type Sender } from './rooms';
|
||||||
import { AccountStore } from './accounts';
|
import { AccountStore } from './accounts';
|
||||||
import { CloudStore } from './campaigns';
|
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 8787);
|
const PORT = Number(process.env.PORT ?? 8787);
|
||||||
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
|
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 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 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 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 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 bearer = (req: FastifyRequest): string | undefined => {
|
||||||
const h = req.headers.authorization;
|
const h = req.headers.authorization;
|
||||||
@@ -29,30 +33,49 @@ const RATE = { capacity: 80, refillMs: 10_000 };
|
|||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
export function buildServer() {
|
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 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();
|
void accounts.load();
|
||||||
const cloud = new CloudStore(DATA_DIR);
|
const cloud = new CloudStore(DATA_DIR);
|
||||||
void cloud.load();
|
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));
|
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<string, { n: number; reset: number }>();
|
const ipHits = new Map<string, { n: number; reset: number }>();
|
||||||
|
const authHits = new Map<string, { n: number; reset: number }>();
|
||||||
|
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
|
||||||
|
const bump = (map: Map<string, { n: number; reset: number }>, 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) => {
|
app.addHook('onRequest', async (req, reply) => {
|
||||||
if (!req.url.startsWith('/api/')) return;
|
if (!req.url.startsWith('/api/')) return;
|
||||||
const now = Date.now();
|
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
|
||||||
const e = ipHits.get(req.ip);
|
// Strip the querystring before matching the auth path.
|
||||||
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
|
const path0 = req.url.split('?')[0]!;
|
||||||
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
|
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 ----
|
// ---- accounts + cloud backup sync ----
|
||||||
app.post('/api/register', async (req, reply) => {
|
app.post('/api/register', async (req, reply) => {
|
||||||
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
||||||
const r = await accounts.register(String(username ?? ''), String(password ?? ''));
|
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 };
|
return { token: r.token, username: r.username };
|
||||||
});
|
});
|
||||||
app.post('/api/login', async (req, reply) => {
|
app.post('/api/login', async (req, reply) => {
|
||||||
@@ -67,6 +90,11 @@ export function buildServer() {
|
|||||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
||||||
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
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
|
// Optimistic concurrency: if the caller names the version it last synced and
|
||||||
// the cloud has since moved on (another device pushed), refuse — no silent
|
// the cloud has since moved on (another device pushed), refuse — no silent
|
||||||
// overwrite. The client then resolves (pull, or force-push).
|
// 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 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 } };
|
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 (!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 });
|
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 };
|
return { ok: true, updatedAt: r.updatedAt };
|
||||||
});
|
});
|
||||||
app.get('/api/campaigns/:id/characters', async (req, reply) => {
|
app.get('/api/campaigns/:id/characters', async (req, reply) => {
|
||||||
@@ -141,7 +176,7 @@ export function buildServer() {
|
|||||||
app.get('/api/usage', async (req, reply) => {
|
app.get('/api/usage', async (req, reply) => {
|
||||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
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));
|
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) ----
|
// ---- 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 } });
|
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
|
||||||
|
|
||||||
|
// Live WebSocket connections per client IP — a hard cap on socket-spam.
|
||||||
|
const wsPerIp = new Map<string, number>();
|
||||||
|
|
||||||
void app.register(async (instance) => {
|
void app.register(async (instance) => {
|
||||||
instance.get('/ws', { websocket: true }, (socket, req) => {
|
instance.get('/ws', { websocket: true }, (socket, req) => {
|
||||||
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
|
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
|
||||||
@@ -174,6 +212,11 @@ export function buildServer() {
|
|||||||
socket.close(1008, 'origin');
|
socket.close(1008, 'origin');
|
||||||
return;
|
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 */ } } };
|
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
|
||||||
let tokens = RATE.capacity;
|
let tokens = RATE.capacity;
|
||||||
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
||||||
@@ -209,11 +252,11 @@ export function buildServer() {
|
|||||||
case 'setName': hub.setName(sender, m.name); break;
|
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).
|
// Serve the built SPA with history fallback (so /play?room=... deep-links work).
|
||||||
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
|
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
|
||||||
|
|||||||
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
|
|||||||
expect(hub.roomCount()).toBe(0);
|
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<ServerMessage, { t: 'hosted' }>;
|
||||||
|
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', () => {
|
it('routes a seat claim → GM grant → player gets their sheet', () => {
|
||||||
const hub = new RoomHub();
|
const hub = new RoomHub();
|
||||||
const { gm, player, secret } = hostAndJoin(hub);
|
const { gm, player, secret } = hostAndJoin(hub);
|
||||||
|
|||||||
+30
-2
@@ -34,6 +34,8 @@ interface Room {
|
|||||||
players: Set<Sender>;
|
players: Set<Sender>;
|
||||||
snapshot: Snapshot | null;
|
snapshot: Snapshot | null;
|
||||||
images: Map<string, string>;
|
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 */
|
/** granted seats keyed by playerId */
|
||||||
seats: Map<string, Seat>;
|
seats: Map<string, Seat>;
|
||||||
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
|
/** 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 JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
|
||||||
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
|
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 {
|
function sha256(s: string): string {
|
||||||
return crypto.createHash('sha256').update(s).digest('hex');
|
return crypto.createHash('sha256').update(s).digest('hex');
|
||||||
@@ -62,7 +71,10 @@ export class RoomHub {
|
|||||||
private rooms = new Map<string, Room>();
|
private rooms = new Map<string, Room>();
|
||||||
private byCode = new Map<string, string>();
|
private byCode = new Map<string, string>();
|
||||||
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
|
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
|
||||||
constructor(private now: () => number = () => Date.now()) {}
|
private limits: RoomLimits;
|
||||||
|
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
|
||||||
|
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||||
|
}
|
||||||
|
|
||||||
private mintJoinCode(): string {
|
private mintJoinCode(): string {
|
||||||
for (let attempt = 0; attempt < 50; attempt++) {
|
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 roomId = crypto.randomUUID();
|
||||||
const joinCode = this.mintJoinCode();
|
const joinCode = this.mintJoinCode();
|
||||||
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
||||||
passwordHash: password ? sha256(password) : null,
|
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(),
|
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
|
||||||
};
|
};
|
||||||
this.rooms.set(roomId, room);
|
this.rooms.set(roomId, room);
|
||||||
@@ -148,7 +166,17 @@ export class RoomHub {
|
|||||||
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
||||||
const room = this.gmRoom(socket, gmSecret);
|
const room = this.gmRoom(socket, gmSecret);
|
||||||
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
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.images.set(id, dataUrl);
|
||||||
|
room.imageBytes += size - prevSize;
|
||||||
room.lastActivity = this.now();
|
room.lastActivity = this.now();
|
||||||
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function CloudCampaigns() {
|
|||||||
const [targetCloud, setTargetCloud] = useState('');
|
const [targetCloud, setTargetCloud] = useState('');
|
||||||
const [viewing, setViewing] = useState<string | null>(null);
|
const [viewing, setViewing] = useState<string | null>(null);
|
||||||
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
||||||
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<AdminUserRow[]>([]);
|
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
|
||||||
|
|
||||||
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
||||||
@@ -122,11 +122,25 @@ export function CloudCampaigns() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{usage && <p className="text-xs text-muted">Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span></p>}
|
{usage && (() => {
|
||||||
|
const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0;
|
||||||
|
const near = pct >= 90;
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className={near ? 'text-warning' : 'text-muted'}>
|
||||||
|
Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span> of {fmtBytes(usage.quota)} ({pct}%)
|
||||||
|
{near && ' — almost full; trim old data or ask the admin to raise your quota.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-elevated">
|
||||||
|
<div className={near ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{usage?.admin && (
|
{usage?.admin && (
|
||||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
||||||
<h3 className="mb-1 smallcaps">Admin · users & storage</h3>
|
<h3 className="mb-1 smallcaps">Admin · users & storage <span className="font-normal text-muted">(you are signed in as the admin)</span></h3>
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export async function cloudUsageBytes(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
|
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
|
||||||
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
|
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
|
||||||
return req<{ bytes: number; admin: boolean }>('/usage');
|
return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
|
||||||
}
|
}
|
||||||
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
|
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
|
||||||
return req<{ users: AdminUserRow[] }>('/admin/users');
|
return req<{ users: AdminUserRow[] }>('/admin/users');
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult
|
|||||||
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
|
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
|
||||||
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
||||||
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
|
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) 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.');
|
if (!res.ok) throw new CloudError('Upload failed.');
|
||||||
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
|
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
|
||||||
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
|
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
|
||||||
|
|||||||
@@ -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('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('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('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('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() }),
|
z.object({ t: z.literal('requestImage'), id: z.string().max(128) }),
|
||||||
// two-way play (player → server)
|
// two-way play (player → server)
|
||||||
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
|
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
|
||||||
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
||||||
|
|||||||
Reference in New Issue
Block a user