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:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user