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
+21
View File
@@ -0,0 +1,21 @@
/**
* @file avatar-redirect.js
* @description Answers an avatar GET (`/api/me/avatar`, `/api/users/:id/avatar`)
* with a 302 to the account's stored avatar LINK (server/lib/avatars.js), so
* the viewer's browser loads the picture itself — with its own logins and
* network — and the server never fetches it. No referrer is sent with it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** True when the account has a link and the redirect was sent. */
function sendAvatarLink(res, userId) {
const link = require("./avatars").avatarLink(userId);
if (!link) return false;
res.set("Referrer-Policy", "no-referrer");
// The client versions the URL with `avatarAt`, so a new link is a new URL.
res.set("Cache-Control", "private, max-age=86400");
res.redirect(302, link);
return true;
}
module.exports = { sendAvatarLink };
+22 -100
View File
@@ -1,26 +1,19 @@
/**
* @file avatar-url.js
* @description Fetches an avatar picture from a URL the account typed
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
* browser, which most image hosts' CORS would block — downloads it. A URL the
* user names makes the SERVER connect somewhere, so it is held to: `https://`
* with no userinfo, a host whose EVERY resolved address is public (no
* loopback, private, link-local, CGNAT, multicast or reserved range — the
* dashboard's own network is never reachable this way), the connection pinned
* to the vetted address (a second DNS answer cannot rebind it), at most
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
* @description Checks an avatar LINK the account typed (Settings → Account,
* `POST /api/me/avatar/url`). The link is stored as it is and the avatar
* routes redirect the viewer's browser to it — the server never downloads it,
* so a picture behind the viewer's own login or on a company-internal host
* (e.g. a Mattermost `/api/v4/users/<id>/image`) still shows. Held to:
* `https://`, no userinfo, at most `MAX_LINK_LENGTH` characters, and no
* loopback/private/link-local/reserved IP literal as the host (a link may not
* aim every viewer's browser at their own machine or LAN).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const dns = require("node:dns");
const https = require("node:https");
const net = require("node:net");
const { MAX_AVATAR_BYTES } = require("./avatars");
const MAX_REDIRECTS = 3;
const TIMEOUT_MS = 10_000;
const MAX_LINK_LENGTH = 2048;
const fail = (code, message) => Object.assign(new Error(message), { code });
@@ -75,100 +68,29 @@ function isPublicAddress(address) {
return !blocked.check(address, "ipv6");
}
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
/** The link, normalised, or EBADURL / EPRIVATEURL: https only, no userinfo, a real host. */
function parseAvatarUrl(text) {
const raw = String(text ?? "").trim();
if (raw.length > MAX_LINK_LENGTH) throw fail("EBADURL", "the link is too long");
let url;
try {
url = new URL(String(text ?? "").trim());
url = new URL(raw);
} catch {
throw fail("EBADURL", "not a valid URL");
}
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
// Node never calls `lookup` for an IP-literal host, so it is checked here.
const literal = url.hostname.replace(/^\[|\]$/g, "");
if (net.isIP(literal) && !isPublicAddress(literal)) {
const host = url.hostname.toLowerCase();
const literal = host.replace(/^\[|\]$/g, "");
if (
host === "localhost" ||
host.endsWith(".localhost") ||
(net.isIP(literal) && !isPublicAddress(literal))
) {
throw fail("EPRIVATEURL", "that address is not on the public internet");
}
return url;
return url.href;
}
/**
* `lookup` for `https.request`: resolves every address and refuses the host
* when ANY of them is not public, then hands over only vetted ones.
*/
function safeLookup(hostname, options, cb) {
if (typeof options === "function") [cb, options] = [options, {}];
const literal = hostname.replace(/^\[|\]$/g, "");
const done = (addrs) => {
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
}
if (options?.all) return cb(null, addrs);
return cb(null, addrs[0].address, addrs[0].family);
};
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
done(addrs);
});
}
function getOnce(url) {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{
lookup: safeLookup,
agent: false,
timeout: TIMEOUT_MS,
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
},
(res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
return resolve({ redirect: new URL(res.headers.location, url).href });
}
if (status !== 200) {
res.resume();
return reject(fail("EFETCH", `the server answered ${status}`));
}
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
res.destroy();
return reject(fail("ETOOBIG", "the image is too large"));
}
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_AVATAR_BYTES) {
res.destroy();
return reject(fail("ETOOBIG", "the image is too large"));
}
chunks.push(chunk);
});
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
}
);
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
req.on("error", (e) =>
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
);
});
}
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
async function fetchAvatarImage(text) {
let url = parseAvatarUrl(text);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const out = await getOnce(url);
if (out.body) return out.body;
url = parseAvatarUrl(out.redirect);
}
throw fail("EFETCH", "too many redirects");
}
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
module.exports = { MAX_LINK_LENGTH, isPublicAddress, parseAvatarUrl };
+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,
};