feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes

- 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.
This commit is contained in:
2026-10-22 14:34:50 +07:00
parent 649f0599f8
commit 26952ae0d9
19 changed files with 463 additions and 289 deletions
+43 -6
View File
@@ -5,7 +5,9 @@
* `<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.
* 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>
@@ -32,10 +34,35 @@ function fileName(userId, ext) {
const db = () => require("../db").db;
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
function removeAvatar(userId) {
const removeFiles = (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));
};
/** 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`. */
@@ -54,7 +81,9 @@ function setAvatar(userId, buf) {
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));
db()
.prepare("UPDATE users SET avatar_at = ?, avatar_link = NULL WHERE id = ?")
.run(at, Number(userId));
return at;
}
@@ -68,4 +97,12 @@ function avatarFile(userId) {
return null;
}
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
module.exports = {
MAX_AVATAR_BYTES,
setAvatar,
setAvatarLink,
removeAvatar,
avatarFile,
avatarLink,
avatarsDir,
};