Proper admin panel at /admin
Replaces the table tucked into Settings with a real instance dashboard, gated server-side to ADMIN_USERS (frontend nav entry appears for admins only). Backend (/api/admin/*, admin-token required): - GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS, default quota, campaign/character totals, live-room load - GET users: created date, usage vs effective quota, session count, admin flag - POST users/:name/revoke — log a user out everywhere - DELETE users/:name — delete account + backup + owned campaigns + published characters (admin accounts are protected from deletion) - GET/DELETE campaigns — oversight + removal incl. published characters - GET rooms — players/seats/images/idle per room; join codes never exposed Frontend: new /admin page (health cards, account management with quota editor + two-click destructive confirms, campaign table, live-room table), ShieldCheck nav entry via useIsAdmin(), Settings now links to the panel instead of embedding it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+85
-8
@@ -4,8 +4,9 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { RoomHub, type Sender } from './rooms';
|
||||
import { AccountStore } from './accounts';
|
||||
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
|
||||
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
@@ -179,26 +180,102 @@ export function buildServer() {
|
||||
return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) };
|
||||
});
|
||||
|
||||
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
|
||||
// ---- admin panel API (accounts in ADMIN_USERS only) ----
|
||||
const startedAt = Date.now();
|
||||
const adminOf = async (req: FastifyRequest) => {
|
||||
const u = await userOf(req);
|
||||
return u && accounts.isAdmin(u.username) ? u : null;
|
||||
};
|
||||
/** Recursive byte total of the data dir (users.json + cloud.json + blobs). */
|
||||
const dirBytes = async (dir: string): Promise<number> => {
|
||||
let total = 0;
|
||||
try {
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) total += await dirBytes(p);
|
||||
else total += (await fs.stat(p)).size.valueOf();
|
||||
}
|
||||
} catch { /* missing dir = 0 */ }
|
||||
return total;
|
||||
};
|
||||
|
||||
app.get('/api/admin/overview', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const mem = process.memoryUsage();
|
||||
const roomStats = hub.stats();
|
||||
const totals = await cloud.totals();
|
||||
return {
|
||||
uptimeMs: Date.now() - startedAt,
|
||||
memory: { rss: mem.rss, heapUsed: mem.heapUsed },
|
||||
dataDirBytes: await dirBytes(DATA_DIR),
|
||||
users: { count: accounts.userCount(), max: Number.isFinite(MAX_USERS) ? MAX_USERS : null },
|
||||
defaultQuotaBytes: DEFAULT_QUOTA_BYTES,
|
||||
campaigns: totals.campaigns,
|
||||
characters: totals.characters,
|
||||
characterBytes: totals.bytes,
|
||||
rooms: {
|
||||
count: roomStats.length,
|
||||
players: roomStats.reduce((n, r) => n + r.players, 0),
|
||||
imageBytes: roomStats.reduce((n, r) => n + r.imageBytes, 0),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const users = await accounts.listUsersDetailed();
|
||||
const rows = await Promise.all(users.map(async (x) => ({
|
||||
username: x.username,
|
||||
admin: x.admin,
|
||||
createdAt: x.createdAt,
|
||||
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
|
||||
quotaBytes: x.quotaBytes ?? 0,
|
||||
quotaBytes: x.quotaBytes,
|
||||
effectiveQuota: x.quotaBytes > 0 ? x.quotaBytes : DEFAULT_QUOTA_BYTES,
|
||||
tokenCount: x.tokenCount,
|
||||
})));
|
||||
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' });
|
||||
if (!await adminOf(req)) 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' });
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/revoke', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const ok = await accounts.revokeTokens((req.params as { username: string }).username);
|
||||
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const name = (req.params as { username: string }).username;
|
||||
const id = await accounts.deleteUser(name);
|
||||
if (!id) return reply.code(400).send({ error: 'cannot-delete', message: 'No such user, or the account is an admin.' });
|
||||
const purged = await cloud.purgeUser(id);
|
||||
return { ok: true, purged };
|
||||
});
|
||||
|
||||
app.get('/api/admin/campaigns', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const byId = new Map((await accounts.listUsers()).map((x) => [x.id, x.username]));
|
||||
const rows = (await cloud.adminList()).map((c) => ({ ...c, owner: byId.get(c.ownerUserId) ?? '(deleted)' }));
|
||||
return { campaigns: rows.sort((a, b) => b.updatedAt - a.updatedAt) };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/campaigns/:id', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const ok = await cloud.deleteCampaign((req.params as { id: string }).id);
|
||||
return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/rooms', async (req, reply) => {
|
||||
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
return { rooms: hub.stats() };
|
||||
});
|
||||
|
||||
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
|
||||
|
||||
// Live WebSocket connections per client IP — a hard cap on socket-spam.
|
||||
|
||||
Reference in New Issue
Block a user