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:
@@ -20,6 +20,11 @@ const UPLOAD_DIR = join(DATA_DIR, 'uploads');
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
export const photoPath = (file: string) => join(UPLOAD_DIR, file);
|
||||
|
||||
// Profile pictures, one per account, named the same way.
|
||||
const AVATAR_DIR = join(DATA_DIR, 'avatars');
|
||||
mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
export const avatarPath = (file: string) => join(AVATAR_DIR, file);
|
||||
|
||||
export const db = new Database(join(DATA_DIR, 'recipescam.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
@@ -73,7 +78,16 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
}
|
||||
}
|
||||
|
||||
export type User = { id: number; email: string };
|
||||
// The avatar column arrived after the first accounts did, same as photos.slot.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'avatar')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// `avatar` is the stored file name, or null for "no picture".
|
||||
export type User = { id: number; email: string; avatar: string | null };
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -109,7 +123,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 };
|
||||
return { id: Number(info.lastInsertRowid), email, avatar: null };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
|
||||
throw err;
|
||||
@@ -118,12 +132,28 @@ 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, password_hash FROM users WHERE email = ?')
|
||||
.prepare('SELECT id, email, avatar, 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 FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
return db.prepare('SELECT id, email, avatar FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
// Swaps the picture and hands back the file it replaced, so the caller can
|
||||
// unlink it — the row is the only index of what is on disk.
|
||||
export function setUserAvatar(id: number, file: string): string | null {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
||||
if (!row) return null;
|
||||
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id);
|
||||
return row.avatar;
|
||||
}
|
||||
|
||||
// Avatars are public by nature — they sit next to a name — so this is not
|
||||
// session-gated. It returns only the row's own file name, never a client path.
|
||||
export function userAvatar(id: number): string | undefined {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
||||
return row?.avatar ?? undefined;
|
||||
}
|
||||
|
||||
export function createSession(userId: number): string {
|
||||
@@ -217,6 +247,38 @@ 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 };
|
||||
|
||||
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
|
||||
FROM users LEFT JOIN photos ON photos.user_id = users.id
|
||||
GROUP BY users.id
|
||||
ORDER BY users.id`,
|
||||
)
|
||||
.all() as AdminUser[];
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function updateUserEmail(id: number, email: string): boolean {
|
||||
try {
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserPassword(id: number, password: string): void {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
||||
}
|
||||
|
||||
export function countPhotos(userId: number): number {
|
||||
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -71,6 +71,9 @@ function actor() {
|
||||
upload(bytes, type) {
|
||||
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
|
||||
},
|
||||
avatar(bytes, type) {
|
||||
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +272,79 @@ try {
|
||||
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
|
||||
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
|
||||
|
||||
// ---- profile: an account edits itself ----------------------------------
|
||||
const JSON_HDR = { 'content-type': 'application/json' };
|
||||
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
|
||||
|
||||
const member = actor();
|
||||
await member.signup(`profile${stamp}@test.local`);
|
||||
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
|
||||
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
|
||||
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
|
||||
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
|
||||
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
|
||||
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
|
||||
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
|
||||
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
|
||||
|
||||
const newEmail = `renamed${stamp}@test.local`;
|
||||
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
|
||||
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
|
||||
const login = (email, password) =>
|
||||
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
|
||||
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
|
||||
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
|
||||
|
||||
// The session that made the edit is the same row, so it also changes the password.
|
||||
const newPassword = 'second-secret-1';
|
||||
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
|
||||
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
|
||||
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
|
||||
check('the new password works', (await login(newEmail, newPassword)).status === 200);
|
||||
|
||||
// ---- admin: the account list -------------------------------------------
|
||||
const anonUsers = await actor().req('/admin/users');
|
||||
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
|
||||
const memberUsers = await member.req('/admin/users');
|
||||
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
|
||||
const adminUsers = await admin.req('/admin/users');
|
||||
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
|
||||
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
|
||||
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
|
||||
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
|
||||
|
||||
// ---- avatar: the picture beside the name -------------------------------
|
||||
const memberId = renamed.body?.user?.id;
|
||||
const noSession = await actor().avatar(PNG, 'image/png');
|
||||
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
|
||||
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
|
||||
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
|
||||
|
||||
const gaveAvatar = await member.avatar(PNG, 'image/png');
|
||||
const avatarUrl = gaveAvatar.body?.user?.avatar;
|
||||
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
|
||||
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
|
||||
|
||||
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
|
||||
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
|
||||
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
|
||||
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
|
||||
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
|
||||
|
||||
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
|
||||
const replacedUrl = replaced.body?.user?.avatar;
|
||||
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
|
||||
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
|
||||
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
|
||||
|
||||
const ghost = await actor().req('/users/999999/avatar');
|
||||
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
|
||||
|
||||
// The moderation list has to show the same face beside the email.
|
||||
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
|
||||
const memberRow = listedUsers.find((u) => u.id === memberId);
|
||||
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
|
||||
|
||||
// ---- 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}`);
|
||||
|
||||
Reference in New Issue
Block a user