750d74a2af
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
175 lines
6.1 KiB
JavaScript
175 lines
6.1 KiB
JavaScript
/**
|
|
* @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.
|
|
* @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 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 URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
|
|
function parseAvatarUrl(text) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(text ?? "").trim());
|
|
} 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)) {
|
|
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* `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 };
|