/** * @file avatars.js * @description A server-mode account's own avatar (Settings → Account, shown * in the sidebar). One raster image per account, stored as * `/avatars/u.` — a name made from the * account id and the type its magic bytes say, never from the request — and * `users.avatar_at` records when it was set, so the client can version the * URL. Raster only: an SVG served from the dashboard's origin runs script. * At most `MAX_AVATAR_BYTES`; the serving route adds `nosniff` and a * `sandbox` CSP like every other user-supplied file. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getDataDir } = require("./claude-home"); const { sniffImage } = require("./pets"); const MAX_AVATAR_BYTES = 1024 * 1024; const EXTS = ["png", "jpg", "gif", "webp"]; const TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" }; const fail = (code, message) => Object.assign(new Error(message), { code }); const avatarsDir = () => path.join(getDataDir(), "avatars"); function fileName(userId, ext) { const id = Number(userId); if (!Number.isSafeInteger(id) || id <= 0) throw fail("EBADFIELD", "bad account id"); return path.join(avatarsDir(), `u${id}.${ext}`); } const db = () => require("../db").db; /** Deletes every stored avatar file of the account and clears `avatar_at`. */ function removeAvatar(userId) { for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true }); db().prepare("UPDATE users SET avatar_at = NULL WHERE id = ?").run(Number(userId)); } /** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */ function setAvatar(userId, buf) { if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EBADIMAGE", "no image sent"); if (buf.length > MAX_AVATAR_BYTES) throw fail("ETOOBIG", "the image is too large"); const ext = sniffImage(buf); if (!ext) throw fail("EBADIMAGE", "only PNG, JPEG, GIF or WebP images"); const target = fileName(userId, ext); fs.mkdirSync(avatarsDir(), { recursive: true, mode: 0o700 }); // Written beside, then renamed: a reader never sees half an image. const tmp = `${target}.${process.pid}.tmp`; fs.writeFileSync(tmp, buf, { mode: 0o600 }); fs.renameSync(tmp, target); for (const other of EXTS) { if (other !== ext) fs.rmSync(fileName(userId, other), { force: true }); } const at = new Date().toISOString(); db().prepare("UPDATE users SET avatar_at = ? WHERE id = ?").run(at, Number(userId)); return at; } /** The account's avatar file and its type, or null when it has none. */ function avatarFile(userId) { for (const ext of EXTS) { const file = fileName(userId, ext); const st = fs.lstatSync(file, { throwIfNoEntry: false }); if (st?.isFile()) return { path: file, type: TYPES[ext] }; } return null; } module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };