26952ae0d9
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users/<id>/image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat.
109 lines
3.9 KiB
JavaScript
109 lines
3.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. Or, instead of a file, a LINK (`users.avatar_link`, checked by
|
|
* avatar-url.js) the serving routes redirect the browser to — never
|
|
* downloaded. Setting one clears the other. 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;
|
|
|
|
const removeFiles = (userId) => {
|
|
for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true });
|
|
};
|
|
|
|
/** Deletes every stored avatar file of the account and clears `avatar_at` and the link. */
|
|
function removeAvatar(userId) {
|
|
removeFiles(userId);
|
|
db()
|
|
.prepare("UPDATE users SET avatar_at = NULL, avatar_link = NULL WHERE id = ?")
|
|
.run(Number(userId));
|
|
}
|
|
|
|
/** Stores `text` (EBADURL / EPRIVATEURL) as the account's avatar link; returns `avatar_at`. */
|
|
function setAvatarLink(userId, text) {
|
|
const link = require("./avatar-url").parseAvatarUrl(text);
|
|
removeFiles(userId);
|
|
const at = new Date().toISOString();
|
|
db()
|
|
.prepare("UPDATE users SET avatar_at = ?, avatar_link = ? WHERE id = ?")
|
|
.run(at, link, Number(userId));
|
|
return at;
|
|
}
|
|
|
|
/** The account's avatar link, or null when it has none. */
|
|
function avatarLink(userId) {
|
|
return (
|
|
db().prepare("SELECT avatar_link FROM users WHERE id = ?").get(Number(userId))?.avatar_link ||
|
|
null
|
|
);
|
|
}
|
|
|
|
/** 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 = ?, avatar_link = NULL 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,
|
|
setAvatarLink,
|
|
removeAvatar,
|
|
avatarFile,
|
|
avatarLink,
|
|
avatarsDir,
|
|
};
|