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
+7 -5
View File
@@ -27,8 +27,8 @@
* owner) may not change the owner's login. Each change is audited.
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` downloads the same
* from an https link (public addresses only, server/lib/avatar-url.js) and
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
@@ -264,11 +264,13 @@ router.post(
);
const avatars = require("../lib/avatars");
const { sendAvatarLink } = require("../lib/avatar-redirect");
router.get("/avatar", (req, res) => {
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
if (sendAvatarLink(res, user.id)) return;
const file = avatars.avatarFile(user.id);
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);
@@ -289,13 +291,13 @@ router.put(
})
);
// The same picture from a link: the server downloads it (server/lib/avatar-url.js).
// A picture by link: stored as it is, never downloaded — GET /avatar redirects
// the browser to it (server/lib/avatar-url.js).
router.post(
"/avatar/url",
sameOriginGuard,
accountWrite(async (_req, user, body) => {
const buf = await require("../lib/avatar-url").fetchAvatarImage(body.url);
avatars.setAvatar(user.id, buf);
avatars.setAvatarLink(user.id, body.url);
return { out: accountView(user.id), action: "own avatar set from a link" };
})
);
+6
View File
@@ -209,6 +209,12 @@ router.get("/:id", (req, res) => {
router.get("/:id/avatar", (req, res) => {
const id = Number(req.params.id);
if (
Number.isSafeInteger(id) &&
id > 0 &&
require("../lib/avatar-redirect").sendAvatarLink(res, id)
)
return;
const file = Number.isSafeInteger(id) && id > 0 ? require("../lib/avatars").avatarFile(id) : null;
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);