web: moderate accounts, accept 12MB uploads, put SAVE under CREATE

- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
This commit is contained in:
2026-09-18 10:33:35 +07:00
parent 8e6c1493e8
commit 43d86b4b6f
12 changed files with 386 additions and 45 deletions
+92 -12
View File
@@ -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.
+65 -2
View File
@@ -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' });