Files
AgentHub/server/lib/ai-tool-icons.js
T

110 lines
4.0 KiB
JavaScript

/**
* @file Server-side favicon cache for the AI Tools list, so the browser never
* calls a third-party site. One `https://<domain>/favicon.ico` fetch per tool
* — the domain is the catalog's `faviconDomain` or the homepage host, a dotted
* hostname only (no IP literal, no port) — kept in `<data dir>/runtime/
* ai-tool-icons/`. Raster types only, recognised by their MAGIC BYTES — the
* header is ignored, since a site may label anything image/png (or send no
* type at all) — because an SVG or HTML page served from the dashboard's own
* origin would run script if its URL were opened directly. A miss is
* remembered for a day.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const aiTools = require("./ai-tools");
// Multi-resolution .ico files are routinely 100 KB+ (cursor.com's is 115 KB).
const MAX_BYTES = 256 * 1024;
const MISS_MS = 24 * 60 * 60 * 1000;
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
/** The raster type of `body` by its signature, or null (SVG, HTML, anything else). */
function sniff(body) {
const hex = body.subarray(0, 4).toString("hex");
if (hex === "89504e47") return "image/png";
if (hex === "00000100") return "image/x-icon";
if (hex === "47494638") return "image/gif";
if (hex.startsWith("ffd8ff")) return "image/jpeg";
if (hex === "52494646" && body.subarray(8, 12).toString("latin1") === "WEBP") {
return "image/webp";
}
return null;
}
const iconDir = () => path.join(getDataDir(), "runtime", "ai-tool-icons");
/** The domain to ask, or null when the tool names none we may fetch. */
function iconDomain(tool) {
let d = tool?.faviconDomain;
if (!d && tool?.homepageUrl) {
try {
const u = new URL(tool.homepageUrl);
d = u.port ? null : u.hostname;
} catch {
d = null;
}
}
d = typeof d === "string" ? d.toLowerCase() : null;
return d && DOMAIN_RE.test(d) ? d : null;
}
function readCached(id) {
const dir = iconDir();
try {
const meta = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
if (meta.miss) return Date.now() - meta.at < MISS_MS ? { miss: true } : null;
return { type: meta.type, body: fs.readFileSync(path.join(dir, `${id}.img`)) };
} catch {
return null;
}
}
function writeCached(id, hit) {
const dir = iconDir();
fs.mkdirSync(dir, { recursive: true });
if (hit) fs.writeFileSync(path.join(dir, `${id}.img`), hit.body);
const meta = hit ? { type: hit.type, at: Date.now() } : { miss: true, at: Date.now() };
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(meta));
}
// ponytail: only the root /favicon.ico — ~22 of the 38 catalog sites serve a
// raster one there; the rest keep their letter. Parse the homepage's
// <link rel="icon"> if more real icons are wanted (many of those are SVG).
async function fetchIcon(domain) {
try {
const res = await fetch(`https://${domain}/favicon.ico`, {
signal: AbortSignal.timeout(10_000),
redirect: "follow",
});
const final = new URL(res.url || `https://${domain}/`);
const sameSite = aiTools.siteOf(final.hostname) === aiTools.siteOf(domain);
if (!res.ok || final.protocol !== "https:" || !sameSite) return null;
const body = Buffer.from(await res.arrayBuffer());
if (body.length > MAX_BYTES) return null;
const type = sniff(body);
return type ? { type, body } : null;
} catch {
return null;
}
}
/** `{type, body}` for tool `id`, or null (letter avatar). Never throws. */
async function iconFor(id) {
const tool = aiTools.get(id);
const domain = iconDomain(tool);
if (!domain) return null;
const cached = readCached(id);
if (cached) return cached.miss ? null : cached;
const hit = await fetchIcon(domain);
try {
writeCached(id, hit);
} catch {
/* a read-only data dir only costs the cache */
}
return hit;
}
module.exports = { iconFor, iconDomain };