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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+24
-1
@@ -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<string, User>();
|
||||
private queue: Promise<unknown> = 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<Array<{ id: string; username: string; quotaBytes?: number }>> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; }
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
this.loaded = true;
|
||||
|
||||
+24
-2
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user