web: account avatars, member /profile, framed admin panel

- an account can carry a picture: POST /api/auth/avatar (raw bytes,
  sniffed, replaces and unlinks the old file) and the public
  GET /api/users/:id/avatar. It rides wherever the account is named —
  the landing chip, the studio TopBar, the profile form.
- new /profile page for members, sharing one Profile form (picture,
  email, password) with the admin drawer.
- /admin is now one bordered frame whose left column is
  Profile / User account / Pictures / Close. Pictures lists every
  photo in the system with the slot that shows it; User account lists
  each account's name, email, picture and contribution count.
- account control opens a menu: Admin page + Log out for an admin,
  Profile + Log out for a member.
This commit is contained in:
2026-09-18 08:19:36 +07:00
parent 2917c034ed
commit 6bbf77860b
15 changed files with 908 additions and 79 deletions
+113 -2
View File
@@ -23,11 +23,17 @@ import {
listPhotos,
listPhotosWithOwner,
listRecipes,
listUsersWithCounts,
avatarPath,
photoFile,
photoPath,
sessionUser,
setPhotoSlot,
setUserAvatar,
setUserPassword,
updateRecipe,
updateUserEmail,
userAvatar,
verifyPassword,
type Recipe,
type User,
@@ -56,7 +62,15 @@ const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user) });
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
const publicUser = (user: User) => ({
id: user.id,
email: user.email,
admin: isAdmin(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
const app = Fastify({
logger: true,
@@ -125,6 +139,7 @@ function sniffImage(buf: Buffer): ImageMime | null {
return null;
}
const EXT: Record<ImageMime, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
const AVATAR_MIME: Record<string, ImageMime> = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
// Single error shape for the whole API: { error: "..." }
app.setErrorHandler((err, req, reply) => {
@@ -219,7 +234,7 @@ app.post('/api/auth/login', async (req, reply) => {
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
setSession(req, reply, createSession(row.id));
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email }) });
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) });
});
app.post('/api/auth/logout', async (req, reply) => {
@@ -237,6 +252,37 @@ app.get('/api/auth/me', async (req, reply) => {
return reply.status(200).send({ user: user ? publicUser(user) : null });
});
// Profile: the signed-in account edits its own email or password. The current
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowLogin(user.email)) return tooMany(reply);
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });
const row = findUserByEmail(user.email);
const current = typeof b.currentPassword === 'string' ? b.currentPassword : '';
if (!row || !verifyPassword(current, row.password_hash))
return reply.status(403).send({ error: 'invalid password' });
let email = user.email;
if (b.email !== undefined) {
const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' });
if (next !== user.email && !updateUserEmail(user.id, next))
return reply.status(409).send({ error: 'email already registered' });
email = next;
}
if (b.password !== undefined) {
const password = typeof b.password === 'string' ? b.password : '';
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
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 }) });
});
app.get('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -306,6 +352,49 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply
return reply.status(201).send({ photo });
});
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
const mime = sniffImage(body);
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
writeFileSync(avatarPath(file), body, { flag: 'wx' });
const previous = setUserAvatar(user.id, file);
if (previous) unlinkAvatar(previous);
return reply.status(200).send({ user: publicUser({ ...user, avatar: file }) });
});
// Public on purpose: an avatar sits next to a name on the landing page, so
// there is nothing here a session would protect.
app.get<{ Params: { id: string } }>('/api/users/:id/avatar', async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
const file = userAvatar(id);
const type = file ? AVATAR_MIME[file.split('.').pop() ?? ''] : undefined;
if (!file || !type) return reply.status(404).send({ error: 'not_found' });
let data: Buffer;
try {
data = readFileSync(avatarPath(file));
} catch {
return reply.status(404).send({ error: 'not_found' });
}
// The URL carries the file's own name as a version, so it can never go stale.
return reply
.header('content-type', type)
.header('cache-control', 'public, max-age=31536000, immutable')
.send(data);
});
app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
@@ -349,6 +438,28 @@ function unlink(file: string): void {
}
}
function unlinkAvatar(file: string): void {
try {
unlinkSync(avatarPath(file));
} catch {
// Already gone; the row is what matters.
}
}
// Accounts and how much each one has contributed — the "who is this" half of
// moderation. `admin` is the allowlist's answer, not a stored column.
app.get('/api/admin/users', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
return reply.status(200).send({
users: listUsersWithCounts().map((u) => ({
...u,
avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null,
admin: ADMIN_EMAILS.has(u.email),
})),
});
});
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' });