char-slop/stars.hackery.site

git clone https://git.t4t.associates/char-slop/stars.hackery.site

Charlotte Somsyntactic pass: no casts, assertions, or void92004f9

main
6.0 KiB182 linesraw
1import { lookup } from "node:dns/promises";
2import http, { type IncomingMessage } from "node:http";
3import https from "node:https";
4import { DatabaseSync } from "node:sqlite";
5import ipaddr from "ipaddr.js";
6import { type DefaultTreeAdapterTypes as Tree, parse } from "parse5";
7import { HttpError, readBytes } from "./http.ts";
8import { starUrl } from "./records.ts";
9
10export type TitlePreview = { title: string; source: "og" | "html" | "url" };
11
12const text = (node: Tree.Node) => ("value" in node ? node.value : "");
13const clean = (raw: string | undefined) => {
14  const title = raw?.replace(/\s+/gu, " ").trim() ?? "";
15  return Array.from(title).slice(0, 300).join("");
16};
17
18export function parseTitle(
19  html: string,
20): { title: string; source: "og" | "html" } | undefined {
21  let og: string | undefined;
22  let heading: string | undefined;
23  const visit = (node: Tree.Node) => {
24    if ("tagName" in node && node.tagName === "meta" && !og) {
25      const attrs = new Map(node.attrs.map(({ name, value }) => [name, value]));
26      if (attrs.get("property")?.toLowerCase() === "og:title") {
27        og = attrs.get("content");
28      }
29    }
30    if ("tagName" in node && node.tagName === "title" && !heading) {
31      heading = node.childNodes.map(text).join("");
32    }
33    if ("childNodes" in node) node.childNodes.forEach(visit);
34  };
35  visit(parse(html));
36  const title = clean(og);
37  if (title) return { title, source: "og" };
38  const fallback = clean(heading);
39  if (fallback) return { title: fallback, source: "html" };
40}
41
42export function publicAddress(address: string): boolean {
43  try {
44    return ipaddr.parse(address).range() === "unicast";
45  } catch {
46    return false;
47  }
48}
49
50type Address = { address: string; family: number };
51
52// Resolves a URL to one public address, which the request then pins so that
53// DNS can't rebind it to something private mid-flight.
54async function publicDestination(url: URL): Promise<Address | undefined> {
55  if (
56    !["http:", "https:"].includes(url.protocol) || url.port ||
57    url.username || url.password
58  ) return;
59  const hostname = url.hostname.replace(/^\[|\]$/g, "");
60  const addresses = await lookup(hostname, { all: true });
61  if (!addresses.length || !addresses.every((a) => publicAddress(a.address))) {
62    return;
63  }
64  return addresses.find(({ family }) => family === 4) ?? addresses[0];
65}
66
67function request(url: URL, { address, family }: Address) {
68  return new Promise<IncomingMessage>((resolve, reject) => {
69    const transport = url.protocol === "https:" ? https : http;
70    transport.get(url, {
71      lookup: (_hostname, options, callback) =>
72        options.all
73          ? callback(null, [{ address, family }])
74          : callback(null, address, family),
75      signal: AbortSignal.timeout(8000),
76      maxHeaderSize: 8192,
77      headers: {
78        accept: "text/html, application/xhtml+xml",
79        "accept-encoding": "identity",
80        "user-agent": "Starboard title preview/1.0",
81      },
82    }, resolve).on("error", reject);
83  });
84}
85
86const REDIRECTS = new Set([301, 302, 303, 307, 308]);
87const HTML = /^\s*(text\/html|application\/xhtml\+xml)(?:\s*;|\s*$)/i;
88
89// The page's HTML, following up to three redirects and re-checking each hop.
90async function page(url: URL): Promise<string | undefined> {
91  for (let hops = 0;; hops++) {
92    const destination = await publicDestination(url);
93    if (!destination) return;
94    const response = await request(url, destination);
95    const { statusCode, headers } = response;
96    const location = headers.location;
97    if (REDIRECTS.has(statusCode ?? 0) && location && hops < 3) {
98      response.destroy();
99      url = new URL(location, url);
100      continue;
101    }
102    const contentType = headers["content-type"] ?? "";
103    const encoding = headers["content-encoding"] ?? "identity";
104    if (
105      statusCode !== 200 || !HTML.test(contentType) || encoding !== "identity"
106    ) {
107      response.destroy();
108      return;
109    }
110    const bytes = await readBytes(response, 8 * 1024 * 1024);
111    const charset = /charset\s*=\s*([^;\s]+)/i.exec(contentType)?.[1];
112    return new TextDecoder(charset ?? "utf-8").decode(bytes);
113  }
114}
115
116export async function lookupTitle(link: string): Promise<TitlePreview> {
117  try {
118    const html = await page(new URL(link));
119    const found = html && parseTitle(html);
120    if (found) return found;
121  } catch {
122    /* Network and markup failures leave the URL as the editable title. */
123  }
124  return { title: link, source: "url" };
125}
126
127export class TitleRateLimit extends HttpError {
128  constructor() {
129    super(429, "Title lookup limit reached", { "retry-after": "600" });
130  }
131}
132export class TitleBusy extends HttpError {
133  constructor() {
134    super(503, "Too many title lookups");
135  }
136}
137
138export class TitleLookup {
139  private active = 0;
140  private lastPruned = -1;
141
142  constructor(
143    readonly db: DatabaseSync,
144    readonly resolve: (url: string) => Promise<TitlePreview> = lookupTitle,
145  ) {
146    db.exec(`CREATE TABLE IF NOT EXISTS title_lookup (
147      did TEXT PRIMARY KEY, bucket INTEGER NOT NULL, count INTEGER NOT NULL
148    )`);
149    db.exec(
150      "CREATE INDEX IF NOT EXISTS title_lookup_bucket ON title_lookup(bucket)",
151    );
152  }
153
154  async lookup(did: string, raw: string): Promise<TitlePreview> {
155    const url = starUrl(raw);
156    if (!url) throw new Error("Invalid link");
157    if (this.active >= 8) throw new TitleBusy();
158    const bucket = Math.floor(Date.now() / 600000);
159    if (bucket !== this.lastPruned) {
160      this.db.prepare("DELETE FROM title_lookup WHERE bucket < ?").run(
161        bucket - 144,
162      );
163      this.lastPruned = bucket;
164    }
165    const row = this.db.prepare(`
166      INSERT INTO title_lookup (did, bucket, count) VALUES (?, ?, 1)
167      ON CONFLICT(did) DO UPDATE SET
168        count = CASE WHEN title_lookup.bucket = excluded.bucket
169          THEN title_lookup.count + 1 ELSE 1 END,
170        bucket = excluded.bucket
171      WHERE title_lookup.bucket != excluded.bucket OR title_lookup.count < 10
172      RETURNING count
173    `).get(did, bucket);
174    if (!row) throw new TitleRateLimit();
175    this.active++;
176    try {
177      return await this.resolve(url);
178    } finally {
179      this.active--;
180    }
181  }
182}