Files
AgentHub/server/lib/avatars.js
T
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

72 lines
2.9 KiB
JavaScript

/**
* @file avatars.js
* @description A server-mode account's own avatar (Settings → Account, shown
* in the sidebar). One raster image per account, stored as
* `<data dir>/avatars/u<id>.<png|jpg|gif|webp>` — 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ĩ <vinnt@smartgift.vn>
*/
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 };