/** * @file Server-side favicon cache for the AI Tools list, so the browser never * calls a third-party site. One `https:///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 `/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ĩ */ 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 // 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 };