diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 0a47e6a..2ae40d8 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -6,9 +6,13 @@ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; export const SESSION_COOKIE = 'rc_session'; export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days export const MAX_RECIPE_BYTES = 256 * 1024; -// Under nginx's `client_max_body_size 4m`, so an over-limit upload is rejected -// with our JSON error instead of nginx's HTML 413. -export const MAX_PHOTO_BYTES = 3 * 1024 * 1024; +// A phone's 12MP JPEG lands around 4-8MB, so 3MB rejected real photos with a +// 413. The client downscales to 2048px before uploading (see shrinkForUpload), +// which keeps normal uploads well under this; the cap stays generous for a +// full-size PNG or a photo that arrived from elsewhere. Under nginx's +// `client_max_body_size 16m`, so an over-limit upload is still rejected with +// our JSON error instead of nginx's HTML 413. +export const MAX_PHOTO_BYTES = 12 * 1024 * 1024; export const MAX_PHOTOS_PER_USER = 12; const DATA_DIR = process.env.DATA_DIR || './data'; @@ -86,8 +90,28 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot => } } +// Moderation state, added after the first accounts existed: +// blocked — may not sign in (or stay signed in); the row is kept whole. +// deleted_at — "removed" from the site: hidden from the strip, cannot sign +// in, but restorable. A hard DELETE is the separate, final act. +{ + const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'blocked')) { + db.exec(`ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`); + } + if (!cols.some((c) => c.name === 'deleted_at')) { + db.exec(`ALTER TABLE users ADD COLUMN deleted_at TEXT`); + } +} + // `avatar` is the stored file name, or null for "no picture". -export type User = { id: number; email: string; avatar: string | null }; +export type User = { + id: number; + email: string; + avatar: string | null; + blocked: number; + deletedAt: string | null; +}; export type Recipe = { id: number; name: string; @@ -123,7 +147,7 @@ export function createUser(email: string, password: string): User | null { const info = db .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') .run(email, hashPassword(password), now()); - return { id: Number(info.lastInsertRowid), email, avatar: null }; + return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null }; } catch (err) { if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; throw err; @@ -132,12 +156,16 @@ export function createUser(email: string, password: string): User | null { export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined { return db - .prepare('SELECT id, email, avatar, password_hash FROM users WHERE email = ?') + .prepare( + 'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, password_hash FROM users WHERE email = ?', + ) .get(email) as (User & { password_hash: string }) | undefined; } export function findUserById(id: number): User | undefined { - return db.prepare('SELECT id, email, avatar FROM users WHERE id = ?').get(id) as User | undefined; + return db + .prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt FROM users WHERE id = ?') + .get(id) as User | undefined; } // Swaps the picture and hands back the file it replaced, so the caller can @@ -176,7 +204,10 @@ export function sessionUser(token: string): User | undefined { db.prepare('DELETE FROM sessions WHERE token = ?').run(row.token); // lazy cleanup return undefined; } - return findUserById(row.userId); + const user = findUserById(row.userId); + // Belt to the braces of the session sweep in setUserBlocked/setUserRemoved. + if (!user || user.blocked || user.deletedAt) return undefined; + return user; } export function deleteSession(token: string): void { @@ -231,7 +262,12 @@ export type AdminPhoto = Photo & { userId: number; email: string; mime: string; export function listPhotos(): Photo[] { return db - .prepare('SELECT id, created_at AS createdAt, slot FROM photos ORDER BY id DESC') + .prepare( + `SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot + FROM photos JOIN users ON users.id = photos.user_id + WHERE users.deleted_at IS NULL + ORDER BY photos.id DESC`, + ) .all() as Photo[]; } @@ -247,14 +283,24 @@ export function listPhotosWithOwner(): AdminPhoto[] { .all() as AdminPhoto[]; } -// Admin listing: one row per account with how many photos it owns. -export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null }; +// Admin listing: one row per account with how many photos it owns. Blocked and +// removed accounts stay listed — a removed one has to be findable to restore it. +export type AdminUser = { + id: number; + email: string; + createdAt: string; + photos: number; + avatar: string | null; + blocked: number; + deletedAt: string | null; +}; export function listUsersWithCounts(): AdminUser[] { return db .prepare( `SELECT users.id AS id, users.email AS email, users.created_at AS createdAt, - users.avatar AS avatar, COUNT(photos.id) AS photos + users.avatar AS avatar, users.blocked AS blocked, + users.deleted_at AS deletedAt, COUNT(photos.id) AS photos FROM users LEFT JOIN photos ON photos.user_id = users.id GROUP BY users.id ORDER BY users.id`, @@ -262,6 +308,40 @@ export function listUsersWithCounts(): AdminUser[] { .all() as AdminUser[]; } +// ---- moderation ------------------------------------------------------------- +// Blocking and removing both drop the account's live sessions: the state has to +// take effect on the next request, not whenever the cookie happens to expire. +export function setUserBlocked(id: number, blocked: boolean): boolean { + const info = db.prepare('UPDATE users SET blocked = ? WHERE id = ?').run(blocked ? 1 : 0, id); + if (info.changes > 0 && blocked) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + return info.changes > 0; +} + +export function setUserRemoved(id: number, removed: boolean): boolean { + const info = db + .prepare('UPDATE users SET deleted_at = ? WHERE id = ?') + .run(removed ? now() : null, id); + if (info.changes > 0 && removed) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + return info.changes > 0; +} + +// The final act: the row and everything hanging off it. Returns the files the +// caller has to unlink — the rows are the only index of what is on disk. +export function deleteUser(id: number): { photos: string[]; avatar: string | null } | undefined { + const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as + | { avatar: string | null } + | undefined; + if (!row) return undefined; + const photos = (db.prepare('SELECT file FROM photos WHERE user_id = ?').all(id) as { file: string }[]).map( + (r) => r.file, + ); + if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined; + db.prepare('DELETE FROM photos WHERE user_id = ?').run(id); + db.prepare('DELETE FROM recipes WHERE user_id = ?').run(id); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + return { photos, avatar: row.avatar }; +} + // Profile edits. The email column is UNIQUE, so a taken address comes back as // false rather than a thrown constraint; the password uses the same hash the // sign-up path writes. diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index b966f39..036e08a 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -18,7 +18,9 @@ import { deletePhoto, deleteRecipe, deleteSession, + deleteUser, findUserByEmail, + findUserById, isPhotoSlot, listPhotos, listPhotosWithOwner, @@ -30,7 +32,9 @@ import { sessionUser, setPhotoSlot, setUserAvatar, + setUserBlocked, setUserPassword, + setUserRemoved, updateRecipe, updateUserEmail, userAvatar, @@ -233,8 +237,12 @@ app.post('/api/auth/login', async (req, reply) => { const row = findUserByEmail(email); const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH); if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' }); + // Moderation answers after the password check, so the state of an account is + // not something an attacker can probe without its credentials. + if (row.blocked) return reply.status(403).send({ error: 'account blocked' }); + if (row.deletedAt) return reply.status(403).send({ error: 'account removed' }); setSession(req, reply, createSession(row.id)); - return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) }); + return reply.status(200).send({ user: publicUser(row) }); }); app.post('/api/auth/logout', async (req, reply) => { @@ -280,7 +288,7 @@ app.patch('/api/auth/me', async (req, reply) => { return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` }); setUserPassword(user.id, password); } - return reply.status(200).send({ user: publicUser({ id: user.id, email, avatar: user.avatar }) }); + return reply.status(200).send({ user: publicUser({ ...user, email }) }); }); app.get('/api/recipes', async (req, reply) => { @@ -456,10 +464,65 @@ app.get('/api/admin/users', async (req, reply) => { ...u, avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null, admin: ADMIN_EMAILS.has(u.email), + blocked: !!u.blocked, + removed: !!u.deletedAt, })), }); }); +// Moderation of an account. `blocked` stops it signing in; `removed` takes it +// (and its photos) off the site while staying restorable. Both are reversible, +// which is why they share one route — the hard delete is the DELETE below. +// An allowlisted account is never a target: the allowlist is the only source of +// admin privilege, so this also makes "delete yourself" impossible. +function moderatable(reply: FastifyReply, id: number): number | null { + if (!Number.isInteger(id) || id <= 0) { + reply.status(404).send({ error: 'user not found' }); + return null; + } + const target = findUserById(id); + if (!target) { + reply.status(404).send({ error: 'user not found' }); + return null; + } + if (ADMIN_EMAILS.has(target.email.toLowerCase())) { + reply.status(403).send({ error: 'cannot modify an admin account' }); + return null; + } + return id; +} + +app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => { + const user = admin(req); + if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); + const id = moderatable(reply, Number(req.params.id)); + if (id === null) return reply; + const b = bodyOf(req); + if (!b) return reply.status(400).send({ error: 'invalid body' }); + if (b.blocked !== undefined) { + if (typeof b.blocked !== 'boolean') return reply.status(400).send({ error: 'invalid blocked' }); + setUserBlocked(id, b.blocked); + } + if (b.removed !== undefined) { + if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' }); + setUserRemoved(id, b.removed); + } + const row = listUsersWithCounts().find((u) => u.id === id); + return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } }); +}); + +app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => { + const user = admin(req); + if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); + const id = moderatable(reply, Number(req.params.id)); + if (id === null) return reply; + const removed = deleteUser(id); + if (!removed) return reply.status(404).send({ error: 'user not found' }); + for (const file of removed.photos) unlink(file); + if (removed.avatar) unlinkAvatar(removed.avatar); + return reply.status(204).send(); +}); + app.get('/api/admin/photos', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index ddf9c82..9c74d9e 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -187,7 +187,7 @@ try { ); check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400); - const tooBig = Buffer.concat([PNG, Buffer.alloc(4 * 1024 * 1024)]); + const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]); check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413); const created = await user.upload(PNG, 'image/png'); @@ -345,6 +345,57 @@ try { const memberRow = listedUsers.find((u) => u.id === memberId); check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow)); + // ---- moderation: block, remove, delete an account ----------------------- + const target = actor(); + await target.signup(`moderated${stamp}@test.local`); + const targetId = (await target.req('/auth/me')).body?.user?.id; + const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo; + const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`; + const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) }); + check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`); + + const anonModerate = await moderate(actor(), targetId, { blocked: true }); + check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`); + const memberModerate = await moderate(member, targetId, { blocked: true }); + check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`); + const selfModerate = await moderate(admin, adminRow?.id, { blocked: true }); + check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`); + const ghostModerate = await moderate(admin, 999999, { blocked: true }); + check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`); + const badModerate = await moderate(admin, targetId, { blocked: 'yes' }); + check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`); + + const blocked = await moderate(admin, targetId, { blocked: true }); + check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body)); + const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1'); + check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body)); + check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body)); + const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId); + check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow)); + + const unblocked = await moderate(admin, targetId, { blocked: false }); + check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body)); + check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200); + check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id)); + + const removed = await moderate(admin, targetId, { removed: true }); + check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body)); + const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1'); + check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body)); + check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id)); + const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId); + check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow)); + + const restored = await moderate(admin, targetId, { removed: false }); + check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body)); + check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200); + + const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' }); + check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`); + check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId)); + check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401); + check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404); + // ---- pre-existing guarantees still hold --------------------------------- const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' }); check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`); diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index b205956..39c0a27 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -39,7 +39,8 @@ server { # Nginx Proxy Manager in front of this container) so it can mark the session # cookie Secure. Falls back to nothing when that header is absent. proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; - client_max_body_size 4m; + # Room for a full-size upload (the API caps photo bodies at 12m itself). + client_max_body_size 16m; } # SPA fallback: /app, /login and /signup are all index.html. diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 76f5749..127dd77 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -116,6 +116,22 @@ export function Admin() { setTab('pictures'); }; + // Block/unblock and remove/restore share one route; the list is reloaded + // because either flag also changes what the built-in viewer would show. + const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) => + run(async () => { + await api.adminSetUser(id, patch); + await refreshPhotos(); + }); + + const deleteUser = (u: AdminUser) => { + if (!window.confirm(t('adm.userDeleteConfirm', { email: u.email }))) return; + return run(async () => { + await api.adminDeleteUser(u.id); + await refreshPhotos(); + }); + }; + const shown = owner ? photos.filter((p) => p.email === owner) : photos; return ( @@ -270,7 +286,11 @@ export function Admin() { {(u.email[0] ?? '?').toUpperCase()} )} -