···11+CREATE TABLE `favicon_cache` (
22+ `domain` text PRIMARY KEY NOT NULL,
33+ `data` text NOT NULL,
44+ `content_type` text NOT NULL,
55+ `fetched_at` integer NOT NULL
66+);
···11+import { eq } from "drizzle-orm";
22+import { db } from "./db/index.js";
33+import { faviconCache } from "./db/schema.js";
44+55+const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
66+const NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day for "no favicon" entries
77+const FETCH_TIMEOUT = 3000;
88+const MAX_SIZE = 100 * 1024; // 100 KB
99+1010+/** Check whether an IPv4 or IPv6 address is in a private/reserved range. */
1111+function isPrivateIP(ip: string): boolean {
1212+ // IPv4-mapped IPv6 — extract the v4 part
1313+ if (ip.startsWith("::ffff:")) return isPrivateIP(ip.slice(7));
1414+1515+ // IPv4
1616+ const v4 = ip.split(".");
1717+ if (v4.length === 4) {
1818+ const [a, b] = [Number(v4[0]), Number(v4[1])];
1919+ if (a === 10) return true; // 10.0.0.0/8
2020+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
2121+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
2222+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
2323+ if (a === 127) return true; // 127.0.0.0/8
2424+ if (a === 0) return true; // 0.0.0.0/8
2525+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (CGN / Tailscale)
2626+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (benchmarking)
2727+ if (a >= 240) return true; // 240.0.0.0/4 (reserved)
2828+ return false;
2929+ }
3030+3131+ // IPv6
3232+ const normalized = ip.toLowerCase();
3333+ if (normalized === "::1") return true; // loopback
3434+ if (normalized === "::") return true;
3535+ if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // ULA
3636+ if (normalized.startsWith("fe80")) return true; // link-local
3737+ return false;
3838+}
3939+4040+/**
4141+ * Resolve a domain (A + AAAA), reject private IPs, return a safe address.
4242+ * Resolving once and fetching by IP eliminates DNS TOCTOU / rebinding.
4343+ */
4444+async function resolveSafeIP(domain: string): Promise<string | null> {
4545+ try {
4646+ const { resolve } = await import("node:dns/promises");
4747+ const [v4, v6] = await Promise.allSettled([resolve(domain, "A"), resolve(domain, "AAAA")]);
4848+ const addresses = [
4949+ ...(v4.status === "fulfilled" ? v4.value : []),
5050+ ...(v6.status === "fulfilled" ? v6.value : []),
5151+ ];
5252+ if (addresses.length === 0) return null;
5353+ if (!addresses.every((addr) => !isPrivateIP(addr))) return null;
5454+ return addresses[0]!;
5555+ } catch {
5656+ return null;
5757+ }
5858+}
5959+6060+/** Format an IP for use in a URL (brackets for IPv6). */
6161+function formatIPForURL(ip: string): string {
6262+ return ip.includes(":") ? `[${ip}]` : ip;
6363+}
6464+6565+async function tryFetch(
6666+ ip: string,
6767+ domain: string,
6868+ path: string,
6969+): Promise<{ data: Buffer; contentType: string } | null> {
7070+ try {
7171+ const res = await fetch(`https://${formatIPForURL(ip)}${path}`, {
7272+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
7373+ redirect: "error",
7474+ headers: { Host: domain },
7575+ tls: { serverName: domain },
7676+ });
7777+ if (!res.ok) return null;
7878+ const ct = res.headers.get("content-type") ?? "";
7979+ if (!ct.startsWith("image/")) return null;
8080+ const buf = Buffer.from(await res.arrayBuffer());
8181+ if (buf.length === 0 || buf.length > MAX_SIZE) return null;
8282+ return { data: buf, contentType: ct.split(";")[0]! };
8383+ } catch {
8484+ return null;
8585+ }
8686+}
8787+8888+async function fetchFavicon(domain: string): Promise<{ data: Buffer; contentType: string } | null> {
8989+ const ip = await resolveSafeIP(domain);
9090+ if (!ip) return null;
9191+9292+ return (
9393+ (await tryFetch(ip, domain, "/favicon.ico")) ??
9494+ (await tryFetch(ip, domain, "/favicon.svg")) ??
9595+ (await tryFetch(ip, domain, "/favicon.png"))
9696+ );
9797+}
9898+9999+/** Empty marker stored for domains with no favicon, to avoid re-fetching. */
100100+const EMPTY_MARKER = "";
101101+102102+export async function getFavicon(
103103+ domain: string,
104104+): Promise<{ data: Buffer; contentType: string } | null> {
105105+ const row = await db.query.faviconCache.findFirst({
106106+ where: eq(faviconCache.domain, domain),
107107+ });
108108+109109+ if (row) {
110110+ const age = Date.now() - row.fetchedAt.getTime();
111111+ if (row.data === EMPTY_MARKER) {
112112+ if (age < NEGATIVE_TTL_MS) return null;
113113+ } else if (age < TTL_MS) {
114114+ return { data: Buffer.from(row.data, "base64"), contentType: row.contentType };
115115+ }
116116+ }
117117+118118+ const result = await fetchFavicon(domain);
119119+ const now = new Date();
120120+121121+ await db
122122+ .insert(faviconCache)
123123+ .values({
124124+ domain,
125125+ data: result?.data.toString("base64") ?? EMPTY_MARKER,
126126+ contentType: result?.contentType ?? "",
127127+ fetchedAt: now,
128128+ })
129129+ .onConflictDoUpdate({
130130+ target: faviconCache.domain,
131131+ set: {
132132+ data: result?.data.toString("base64") ?? EMPTY_MARKER,
133133+ contentType: result?.contentType ?? "",
134134+ fetchedAt: now,
135135+ },
136136+ });
137137+138138+ return result;
139139+}
+9
lib/lexicons/resolver.ts
···3333}
34343535/**
3636+ * Derive the base domain from an NSID (first two segments, reversed).
3737+ * e.g. "sh.tangled.feed.star" -> "tangled.sh"
3838+ */
3939+export function nsidToDomain(nsid: string): string {
4040+ const parts = nsid.split(".");
4141+ return parts.slice(0, 2).reverse().join(".");
4242+}
4343+4444+/**
3645 * Check whether an NSID is allowed by the instance's allowlist/blocklist.
3746 * Blocklist takes precedence. Supports glob patterns like "app.bsky.*".
3847 */