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 -2
View File
@@ -7,8 +7,9 @@
* another account never sees it through `/api/me`; staff read it at
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
* never reaches a loopback/private address.
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
* downloaded, and the avatar routes redirect to it; https only, never a
* loopback/private IP literal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -110,6 +111,46 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
assert.equal((await put("", PNG)).status, 401);
});
it("a picture from a link is stored and redirected to, never downloaded", async () => {
const ava = await login("ava", "ava-pass-1");
const ann = await login("ann", "ann-pass-1");
const link =
"https://chat.runsystem.vn/api/v4/users/7bya4wy58bdk3qmb5ks1rgoqkh/image?_=1776312011651";
assert.equal((await put(ava, PNG)).status, 200);
const set = await fetch(BASE + "/api/me/avatar/url", {
method: "POST",
headers: { cookie: ava, "Content-Type": "application/json" },
body: JSON.stringify({ url: ` ${link} ` }),
});
assert.equal(set.status, 200);
assert.ok((await set.json()).user.avatarAt);
// The uploaded file is gone: a link replaces it.
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
const got = await fetch(BASE + "/api/me/avatar", {
headers: { cookie: ava },
redirect: "manual",
});
assert.equal(got.status, 302);
assert.equal(got.headers.get("location"), link);
assert.equal(got.headers.get("referrer-policy"), "no-referrer");
const id = auth.findUser("ava").id;
const staff = await fetch(`${BASE}/api/users/${id}/avatar`, {
headers: { cookie: ann },
redirect: "manual",
});
assert.equal(staff.status, 302);
assert.equal(staff.headers.get("location"), link);
// An upload replaces the link.
assert.equal((await put(ava, PNG)).status, 200);
assert.equal(
(await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava }, redirect: "manual" })).status,
200
);
await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
});
it("a picture from a link: https only, never a private or local address", async () => {
const ava = await login("ava", "ava-pass-1");
const post = (url) =>