Files
AgentHub/server/lib/avatar-url.js
T
nntrivi2001 26952ae0d9 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.
2026-10-22 14:34:50 +07:00

97 lines
3.1 KiB
JavaScript

/**
* @file avatar-url.js
* @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 net = require("node:net");
const MAX_LINK_LENGTH = 2048;
const fail = (code, message) => Object.assign(new Error(message), { code });
const blocked = new net.BlockList();
for (const [addr, bits] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
]) {
blocked.addSubnet(addr, bits, "ipv4");
}
for (const [addr, bits] of [
["::", 128],
["::1", 128],
["64:ff9b::", 96],
["64:ff9b:1::", 48],
["100::", 64],
["2001::", 23],
["2001:db8::", 32],
["2002::", 16],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
]) {
blocked.addSubnet(addr, bits, "ipv6");
}
/** True only for a globally routable unicast address. */
function isPublicAddress(address) {
const family = net.isIP(address);
if (family === 4) return !blocked.check(address, "ipv4");
if (family !== 6) return false;
const lower = address.toLowerCase();
// An IPv4-mapped/compatible address is judged as the IPv4 address it carries.
const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
if (mapped) return isPublicAddress(mapped[1]);
if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false;
// Only global unicast (2000::/3) is public.
if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false;
return !blocked.check(address, "ipv6");
}
/** 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(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");
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.href;
}
module.exports = { MAX_LINK_LENGTH, isPublicAddress, parseAvatarUrl };