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--;
}
}
}