Phase 10: accounts + cloud sync + Pathbuilder import
- Lean file-backed accounts (server/src/accounts.ts): scrypt-hashed passwords, hashed bearer tokens, per-user backup blob; persisted under DATA_DIR. /api routes (register/login/logout, PUT/GET /save) with per-IP throttle + 48MB body limit. - Client cloud lib + Settings "Cloud sync": sign up / sign in, back up this device, restore from cloud (whole-backup push/pull, last-write-wins). Local-first default; the assistant key (localStorage) is never part of the synced Dexie backup. - Pathbuilder 2e import: the existing character import now detects a Pathbuilder build JSON and converts it to a PF2e character (abilities/HP/saves/skills). - Dockerfile creates a node-owned /data; compose mounts a named ttrpg-data volume. - (Spectator links = existing /play?room=CODE; PDF export = existing Print.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+53
-2
@@ -2,23 +2,74 @@ import path from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
||||
import { RoomHub, type Sender } from './rooms';
|
||||
import { AccountStore } from './accounts';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
|
||||
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images)
|
||||
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 ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const bearer = (req: FastifyRequest): string | undefined => {
|
||||
const h = req.headers.authorization;
|
||||
return h?.startsWith('Bearer ') ? h.slice(7) : undefined;
|
||||
};
|
||||
|
||||
// per-socket token bucket: 80 messages / 10s
|
||||
const RATE = { capacity: 80, refillMs: 10_000 };
|
||||
|
||||
export function buildServer() {
|
||||
const app = Fastify({ bodyLimit: MAX_PAYLOAD });
|
||||
const app = Fastify({ bodyLimit: BODY_LIMIT });
|
||||
const hub = new RoomHub();
|
||||
const accounts = new AccountStore(DATA_DIR);
|
||||
void accounts.load();
|
||||
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
|
||||
app.addHook('onClose', async () => clearInterval(sweeper));
|
||||
|
||||
// Simple per-IP throttle for the account/cloud API.
|
||||
const ipHits = new Map<string, { n: number; reset: number }>();
|
||||
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' });
|
||||
});
|
||||
|
||||
// ---- 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 });
|
||||
return { token: r.token, username: r.username };
|
||||
});
|
||||
app.post('/api/login', async (req, reply) => {
|
||||
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
||||
const r = await accounts.login(String(username ?? ''), String(password ?? ''));
|
||||
if (!r.ok) return reply.code(401).send({ error: r.code, message: r.message });
|
||||
return { token: r.token, username: r.username };
|
||||
});
|
||||
app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); return { ok: true }; });
|
||||
app.put('/api/save', async (req, reply) => {
|
||||
const u = await accounts.userByToken(bearer(req));
|
||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const blob = (req.body as { blob?: string } | undefined)?.blob;
|
||||
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
||||
await accounts.saveBlob(u.id, blob);
|
||||
return { ok: true, size: blob.length };
|
||||
});
|
||||
app.get('/api/save', async (req, reply) => {
|
||||
const u = await accounts.userByToken(bearer(req));
|
||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const blob = await accounts.loadBlob(u.id);
|
||||
if (blob === null) return reply.code(404).send({ error: 'no-save' });
|
||||
return reply.header('content-type', 'application/json').send(blob);
|
||||
});
|
||||
|
||||
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
|
||||
|
||||
void app.register(async (instance) => {
|
||||
|
||||
Reference in New Issue
Block a user