import { lookup } from "node:dns/promises"; import http, { type IncomingMessage } from "node:http"; import https from "node:https"; import { DatabaseSync } from "node:sqlite"; import ipaddr from "ipaddr.js"; import { type DefaultTreeAdapterTypes as Tree, parse } from "parse5"; import { HttpError, readBytes } from "./http.ts"; import { starUrl } from "./records.ts"; export type TitlePreview = { title: string; source: "og" | "html" | "url" }; const text = (node: Tree.Node) => ("value" in node ? node.value : ""); const clean = (raw: string | undefined) => { const title = raw?.replace(/\s+/gu, " ").trim() ?? ""; return Array.from(title).slice(0, 300).join(""); }; export function parseTitle( html: string, ): { title: string; source: "og" | "html" } | undefined { let og: string | undefined; let heading: string | undefined; const visit = (node: Tree.Node) => { if ("tagName" in node && node.tagName === "meta" && !og) { const attrs = new Map(node.attrs.map(({ name, value }) => [name, value])); if (attrs.get("property")?.toLowerCase() === "og:title") { og = attrs.get("content"); } } if ("tagName" in node && node.tagName === "title" && !heading) { heading = node.childNodes.map(text).join(""); } if ("childNodes" in node) node.childNodes.forEach(visit); }; visit(parse(html)); const title = clean(og); if (title) return { title, source: "og" }; const fallback = clean(heading); if (fallback) return { title: fallback, source: "html" }; } export function publicAddress(address: string): boolean { try { return ipaddr.parse(address).range() === "unicast"; } catch { return false; } } type Address = { address: string; family: number }; // Resolves a URL to one public address, which the request then pins so that // DNS can't rebind it to something private mid-flight. async function publicDestination(url: URL): Promise
{ if ( !["http:", "https:"].includes(url.protocol) || url.port || url.username || url.password ) return; const hostname = url.hostname.replace(/^\[|\]$/g, ""); const addresses = await lookup(hostname, { all: true }); if (!addresses.length || !addresses.every((a) => publicAddress(a.address))) { return; } return addresses.find(({ family }) => family === 4) ?? addresses[0]; } function request(url: URL, { address, family }: Address) { return new Promise((resolve, reject) => { const transport = url.protocol === "https:" ? https : http; transport.get(url, { lookup: (_hostname, options, callback) => options.all ? callback(null, [{ address, family }]) : callback(null, address, family), signal: AbortSignal.timeout(8000), maxHeaderSize: 8192, headers: { accept: "text/html, application/xhtml+xml", "accept-encoding": "identity", "user-agent": "Starboard title preview/1.0", }, }, resolve).on("error", reject); }); } const REDIRECTS = new Set([301, 302, 303, 307, 308]); const HTML = /^\s*(text\/html|application\/xhtml\+xml)(?:\s*;|\s*$)/i; // The page's HTML, following up to three redirects and re-checking each hop. async function page(url: URL): Promise { for (let hops = 0;; hops++) { const destination = await publicDestination(url); if (!destination) return; const response = await request(url, destination); const { statusCode, headers } = response; const location = headers.location; if (REDIRECTS.has(statusCode ?? 0) && location && hops < 3) { response.destroy(); url = new URL(location, url); continue; } const contentType = headers["content-type"] ?? ""; const encoding = headers["content-encoding"] ?? "identity"; if ( statusCode !== 200 || !HTML.test(contentType) || encoding !== "identity" ) { response.destroy(); return; } const bytes = await readBytes(response, 8 * 1024 * 1024); const charset = /charset\s*=\s*([^;\s]+)/i.exec(contentType)?.[1]; return new TextDecoder(charset ?? "utf-8").decode(bytes); } } export async function lookupTitle(link: string): Promise { try { const html = await page(new URL(link)); const found = html && parseTitle(html); if (found) return found; } catch { /* Network and markup failures leave the URL as the editable title. */ } return { title: link, source: "url" }; } export class TitleRateLimit extends HttpError { constructor() { super(429, "Title lookup limit reached", { "retry-after": "600" }); } } export class TitleBusy extends HttpError { constructor() { super(503, "Too many title lookups"); } } export class TitleLookup { private active = 0; private lastPruned = -1; constructor( readonly db: DatabaseSync, readonly resolve: (url: string) => Promise = lookupTitle, ) { db.exec(`CREATE TABLE IF NOT EXISTS title_lookup ( did TEXT PRIMARY KEY, bucket INTEGER NOT NULL, count INTEGER NOT NULL )`); db.exec( "CREATE INDEX IF NOT EXISTS title_lookup_bucket ON title_lookup(bucket)", ); } async lookup(did: string, raw: string): Promise { const url = starUrl(raw); if (!url) throw new Error("Invalid link"); if (this.active >= 8) throw new TitleBusy(); const bucket = Math.floor(Date.now() / 600000); if (bucket !== this.lastPruned) { this.db.prepare("DELETE FROM title_lookup WHERE bucket < ?").run( bucket - 144, ); this.lastPruned = bucket; } const row = this.db.prepare(` INSERT INTO title_lookup (did, bucket, count) VALUES (?, ?, 1) ON CONFLICT(did) DO UPDATE SET count = CASE WHEN title_lookup.bucket = excluded.bucket THEN title_lookup.count + 1 ELSE 1 END, bucket = excluded.bucket WHERE title_lookup.bucket != excluded.bucket OR title_lookup.count < 10 RETURNING count `).get(did, bucket); if (!row) throw new TitleRateLimit(); this.active++; try { return await this.resolve(url); } finally { this.active--; } } }