feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user