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