From ea4522f877ce300df3c1183af415832bb86fa675 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:48:27 +0200 Subject: [PATCH] P15d: storage usage display + owner admin panel - Server: AccountStore gains admin gating (ADMIN_USERS env), per-user quotaBytes override, listUsers + blobBytes. /api/usage now reports total bytes (backup blob + cloud characters) + an admin flag. Admin routes GET /api/admin/users and POST /api/admin/quota (gated). Quotas are tracked, not yet enforced. +1 test. - Client: Settings cloud panel shows "Your cloud storage", and for an admin a users table with per-user usage + an editable quota (GB). compose sets ADMIN_USERS=nilsb. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/ttrpg.compose.yml | 2 ++ server/src/accounts.test.ts | 12 +++++++ server/src/accounts.ts | 25 ++++++++++++- server/src/index.ts | 26 ++++++++++++-- src/features/settings/CloudCampaigns.tsx | 46 +++++++++++++++++++++++- src/lib/cloud/campaigns.ts | 11 ++++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/deploy/ttrpg.compose.yml b/deploy/ttrpg.compose.yml index 6b2d421..0710707 100644 --- a/deploy/ttrpg.compose.yml +++ b/deploy/ttrpg.compose.yml @@ -15,6 +15,8 @@ services: - NODE_ENV=production - DATA_DIR=/data - ALLOWED_ORIGINS=https://ttrpg.briggen.dev + # comma-separated usernames that can see the admin panel (set to your account) + - ADMIN_USERS=nilsb volumes: - ttrpg-data:/data security_opt: diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index e485383..73189ca 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -38,4 +38,16 @@ describe('AccountStore', () => { await store2.load(); expect((await store2.login('dave', 'password123')).ok).toBe(true); }); + + it('gates admin + stores a quota override', async () => { + const adminStore = new AccountStore(dir, undefined, ['boss']); + await adminStore.register('boss', 'password123'); + await adminStore.register('peon', 'password123'); + expect(adminStore.isAdmin('boss')).toBe(true); + expect(adminStore.isAdmin('Boss')).toBe(true); // case-insensitive + expect(adminStore.isAdmin('peon')).toBe(false); + expect(await adminStore.setQuota('peon', 5_000_000_000)).toBe(true); + expect(await adminStore.setQuota('ghost', 1)).toBe(false); + expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000); + }); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index d2f7161..e7fa987 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -16,6 +16,7 @@ interface User { salt: string; hash: string; tokens: string[]; // sha256(token) + quotaBytes?: number; // admin override; 0/undefined = default createdAt: number; updatedAt: number; } @@ -38,11 +39,33 @@ export class AccountStore { private byId = new Map(); private queue: Promise = Promise.resolve(); private loaded = false; - constructor(private dir: string, private now: () => number = () => Date.now()) {} + constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {} 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()); } + + async listUsers(): Promise> { + await this.load(); + return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) })); + } + + async setQuota(username: string, bytes: number): Promise { + await this.load(); + const u = this.users.get(username.toLowerCase()); + if (!u) return false; + u.quotaBytes = Math.max(0, Math.floor(bytes)); + u.updatedAt = this.now(); + await this.persist(); + return true; + } + + /** Size in bytes of a user's stored backup blob (0 if none). */ + async blobBytes(id: string): Promise { + try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; } + } + async load(): Promise { if (this.loaded) return; this.loaded = true; diff --git a/server/src/index.ts b/server/src/index.ts index f7e00d8..94cd161 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), ' 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 ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const bearer = (req: FastifyRequest): string | undefined => { const h = req.headers.authorization; @@ -26,7 +27,7 @@ const RATE = { capacity: 80, refillMs: 10_000 }; export function buildServer() { const app = Fastify({ bodyLimit: BODY_LIMIT }); const hub = new RoomHub(); - const accounts = new AccountStore(DATA_DIR); + const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS); void accounts.load(); const cloud = new CloudStore(DATA_DIR); void cloud.load(); @@ -116,7 +117,28 @@ export function buildServer() { }); app.get('/api/usage', async (req, reply) => { const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); - return { bytes: await cloud.usageBytes(u.id) }; + const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)); + return { bytes, admin: accounts.isAdmin(u.username) }; + }); + + // ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ---- + app.get('/api/admin/users', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); + const users = await accounts.listUsers(); + const rows = await Promise.all(users.map(async (x) => ({ + username: x.username, + usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)), + quotaBytes: x.quotaBytes ?? 0, + }))); + return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) }; + }); + app.post('/api/admin/quota', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); + const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number }; + const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0); + return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' }); }); void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx index 16eaf42..cd4f047 100644 --- a/src/features/settings/CloudCampaigns.tsx +++ b/src/features/settings/CloudCampaigns.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/db/db'; import { cloudUsername, CloudError } from '@/lib/cloud/client'; -import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns'; +import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns'; import { useCampaigns } from '@/features/campaigns/hooks'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; @@ -21,8 +21,13 @@ 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 [admins, setAdmins] = useState([]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); + useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]); + const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {}); + useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]); const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); const run = (fn: () => Promise) => async () => { @@ -105,8 +110,47 @@ export function CloudCampaigns() { + {usage &&

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

} + + {usage?.admin && ( +
+

Admin · users & storage

+ + + + {admins.map((u) => ( + + + + + + ))} + +
UserUsedQuota (GB, 0=default)
{u.username}{fmtBytes(u.usageBytes)}
+

Quotas are tracked but not yet enforced — a safety valve for later.

+
+ )} + {msg &&

{msg}

}

{owned.length > 0 ? 'Share an owner campaign’s invite code with your players. They sign in, join, and publish their characters — you see them under “View party”.' : 'Publish one of your campaigns to get an invite code for players.'}

); } + +function fmtBytes(b: number): string { + if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`; + if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; + return `${Math.max(0, Math.round(b / 1024))} KB`; +} + +function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) { + const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0')); + const [saving, setSaving] = useState(false); + const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } }; + return ( + + setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} /> + + + ); +} diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 852cd7a..51aac4f 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -43,3 +43,14 @@ export function listCloudCharacters(campaignId: string): Promise { return (await req<{ bytes: number }>('/usage')).bytes; } + +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 adminListUsers(): Promise<{ users: AdminUserRow[] }> { + return req<{ users: AdminUserRow[] }>('/admin/users'); +} +export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> { + return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) }); +}