7a88b74016
- 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>
72 lines
2.9 KiB
JavaScript
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 };
|