char-slop/stars.hackery.site
git clone https://git.t4t.associates/char-slop/stars.hackery.site
8463413
main
1import { DatabaseSync } from "node:sqlite" ; 2import * as l from "@cerulea/lexicon" ; 3import { 4Auth , 5deleteRecord , 6scope , 7type Session , 8writeRecord , 9} from "./auth.ts" ; 10import { 11cookie , 12fail , 13HttpError , 14isBareOrigin , 15readBytes , 16readCookie , 17redirect , 18} from "./http.ts" ; 19import { type Actor , Hydrant } from "./hydrant.ts" ; 20import { 21atUri , 22type Did , 23Follow , 24isDid , 25parseAtUri , 26Star , 27starUrl , 28} from "./records.ts" ; 29import * as render from "./render.ts" ; 30import { Index , type IndexedStar } from "./sync.ts" ; 31import { TitleLookup } from "./title.ts" ; 32 33export interface App { 34auth :Auth ; 35hydrant :Hydrant ; 36index :Index ; 37titles :TitleLookup ; 38} 39 40type Context = { req :Request ; url :URL ; app :App ; user ?:Session }; 41type Signed = Context & { user :Session }; 42type Route = ( ctx :Context ) => Response | Promise < Response >; 43 44type Handle = `${string } .${string } `; 45type Fields = Partial < Record < string , string >>; 46 47const isHandle = ( value :string ) :value isHandle => 48l . handle . validate ( value ). success ; 49const isTid = ( value :string ) => l . tid . validate ( value ). success ; 50 51// What a user may type to name an account. 52function identifier ( raw :string | undefined ) :Handle | Did | undefined { 53const value = raw ?. trim (). replace ( / ^@ / , "" ) ?? "" ; 54if ( isDid ( value ) || isHandle ( value )) return value ; 55} 56 57const mediaType = ( req :Request ) => 58req . headers . get ( "content-type" )?. split ( ";" )[ 0 ]; 59 60async function bodyText ( req :Request , maxBytes :number ) :Promise < string > { 61if ( Number ( req . headers . get ( "content-length" )) > maxBytes ) { 62fail ( 413 , "Request body too large" ); 63} 64const body = req . body ?? fail ( 400 , "Missing request body" ); 65return new TextDecoder (). decode ( await readBytes ( body , maxBytes )); 66} 67 68async function readForm ( req :Request ) :Promise < Fields > { 69if ( mediaType ( req ) !== "application/x-www-form-urlencoded" ) { 70fail ( 415 , "Unsupported form encoding" ); 71} 72return Object . fromEntries ( 73new URLSearchParams ( await bodyText ( req , 1_048_576 )), 74); 75} 76 77async function readJson ( req :Request , maxBytes :number ) :Promise < unknown > { 78if ( mediaType ( req ) !== "application/json" ) fail ( 415 , "Expected JSON" ); 79const text = await bodyText ( req , maxBytes ); 80try { 81return JSON . parse ( text ); 82} catch { 83fail ( 400 , "Invalid JSON" ); 84} 85} 86 87// Routes for signed-in users posting a form that carries their CSRF token. 88function form ( 89route :( ctx :Signed , fields :Fields ) => Response | Promise < Response >, 90) :Route { 91return async ( ctx ) => { 92const user = ctx . user ?? fail ( 401 , "Sign in first" ); 93const fields = await readForm ( ctx . req ); 94if ( fields . csrf !== user . csrf ) fail ( 403 , "Invalid form token" ); 95return route ({ ...ctx , user}, fields ); 96}; 97} 98 99async function page ( 100{ app, user} :Context , 101title :string , 102body :string , 103actor ?:Actor , 104) :Promise < Response > { 105if ( ! user ) return render . page ( title , body ); 106const self = actor ?. did === user . did 107 ?actor 108 :await app . hydrant . actor ( user . did ). catch (() => undefined ); 109return render . page ( title , body , { 110handle :self ?. handle ?? user . did , 111url :self ?render . profileUrl ( self ) :render . profilePath ( user . did ), 112}); 113} 114 115async function login ({ req, app} :Context ) :Promise < Response > { 116const fields = await readForm ( req ); 117const handle = identifier ( fields . handle ) ?? 118fail ( 400 , "Enter an ATProto handle or DID" ); 119const { url, stateId} = await app . auth . client . authorize ({ 120target :{ type :"account" , identifier :handle }, 121 scope, 122}); 123return redirect ( 124url . toString (), 125cookie ( "stars_oauth_flow" , stateId , app . auth . secure , 600 ), 126); 127} 128 129async function oauthCallback ({ req, url, app} :Context ) :Promise < Response > { 130const flow = readCookie ( req , "stars_oauth_flow" ); 131if ( ! flow || flow !== url . searchParams . get ( "state" )) { 132fail ( 403 , "Invalid login state" ); 133} 134const { session} = await app . auth . client . callback ( url . searchParams ); 135await app . index . ensureViewer ( session . did ); 136return redirect ( 137"/" , 138app . auth . sessionCookie ( app . auth . createBrowser ( session . did )), 139cookie ( "stars_oauth_flow" , "" , app . auth . secure , 0 ), 140); 141} 142 143function logout ({ app, user} :Signed ) :Response { 144app . auth . deleteBrowser ( user . id ); 145return redirect ( "/" , app . auth . sessionCookie ( "" , 0 )); 146} 147 148function go ({ url} :Context ) :Response { 149const handle = identifier ( url . searchParams . get ( "handle" ) ?? undefined ) ?? 150fail ( 400 , "Invalid identifier" ); 151return redirect ( render . profilePath ( handle )); 152} 153 154// The index only holds URIs; the records and their authors come from Hydrant. 155async function hydrate ( hydrant :Hydrant , rows :IndexedStar []) { 156const authors = [ ...new Set ( rows . map (( row ) => row . author ))]; 157const resolved = await Promise . all ( authors . map (( did ) => hydrant . actor ( did ))); 158const actors = new Map ( authors . map (( did , i ) => [ did , resolved [ i ]])); 159const entries = await Promise . all ( rows . map (( row ) => hydrant . star ( row . uri ))); 160return rows . flatMap (( row , i ) => { 161const actor = actors . get ( row . author ), entry = entries [ i ]; 162return actor && entry ?[{ entry, actor}] :[]; 163}); 164} 165 166async function home ( ctx :Context ) :Promise < Response > { 167const { app, url, user} = ctx ; 168if ( ! user ) return page ( ctx , "Home" , render . welcome ); 169const before = url . searchParams . get ( "before" ) ?? undefined ; 170if ( before && parseAtUri ( before )?. collection !== Star . id ) { 171fail ( 400 , "Invalid cursor" ); 172} 173const viewerReady = await app . index . ensureViewer ( user . did ); 174const { stars, next} = app . index . page ( user . did , before ); 175const hydrated = await hydrate ( app . hydrant , stars ); 176const notice = app . index . ready && app . index . connected && viewerReady 177 ?"" 178 :render . notice ( 179"The feed is catching up with Hydrant or temporarily disconnected." , 180); 181const body = render . feed ( 182user . csrf , 183notice + render . list ( 184hydrated . map (({ entry, actor}) => render . star ( entry , actor )), 185"No stars yet. Follow someone to see their links here." , 186next && `/?before= ${ encodeURIComponent ( next )} ` , 187), 188); 189const self = hydrated . find (({ actor}) => actor . did === user . did )?. actor ; 190return page ( ctx , "Your feed" , body , self ); 191} 192 193async function profile ( ctx :Context , raw :string ) :Promise < Response > { 194const { app, url, user} = ctx ; 195const handle = identifier ( raw ) ?? fail ( 404 , "Not found" ); 196const actor = await app . hydrant . actor ( handle ) ?? fail ( 404 , "Not found" ); 197const before = url . searchParams . get ( "before" ) ?? undefined ; 198if ( before && ! isTid ( before )) fail ( 400 , "Invalid cursor" ); 199const { stars, cursor} = await app . hydrant . stars ( actor . did , before ); 200const follow = user && user . did !== actor . did 201 ?render . followForm ( 202user . csrf , 203actor , 204app . index . following ( user . did , actor . did ). length > 0 , 205) 206 :"" ; 207const csrf = user ?. did === actor . did ?user . csrf :undefined ; 208const body = render . profile ( 209actor , 210follow , 211render . list ( 212stars . map (( entry ) => render . star ( entry , actor , csrf )), 213"No stars yet." , 214cursor && 215` ${ render . profilePath ( handle )} ?before= ${ encodeURIComponent ( cursor )} ` , 216), 217); 218return page ( ctx , actor . handle , body , actor ); 219} 220 221function sharePage ( ctx :Context ) :Response | Promise < Response > { 222const { url, user} = ctx ; 223if ( ! user ) return redirect ( "/login" ); 224return page ( 225ctx , 226"Share a star" , 227render . shareForm ( user . csrf , { 228url :( url . searchParams . get ( "url" ) ?? "" ). slice ( 0 , 2048 ), 229notice :url . searchParams . has ( "shared" ) 230 ?"Starred! Hydrant may take a moment to show your new star." 231 :undefined , 232}), 233); 234} 235 236async function shareTitle ( 237ctx :Signed , 238fields :Fields , 239) :Promise < Response > { 240const { app, user} = ctx ; 241const link = starUrl ( fields . url ) ?? fail ( 400 , "Invalid link" ); 242const preview = await app . titles . lookup ( user . did , link ); 243return page ( 244ctx , 245"Share a star" , 246render . shareForm ( user . csrf , { 247url :link , 248title :preview . title , 249comment :fields . comment , 250notice :preview . source === "url" 251 ?"No page title found. You can edit the URL used as its title." 252 :undefined , 253}), 254); 255} 256 257// Hydrant may have dropped a repo while it was empty, so re-track after writes. 258function retrack ( app :App , did :Did ) :void { 259app . hydrant . track ( did ). catch (( error ) => 260console . error ( "Could not track repo" , did , error ) 261); 262} 263 264async function share ( 265{ app, user} :Signed , 266fields :Fields , 267) :Promise < Response > { 268const link = starUrl ( fields . url ) ?? fail ( 400 , "Invalid link" ); 269const record = Star . build ({ 270url :link , 271title :fields . title ?. trim () || link , 272comment :fields . comment || undefined , 273createdAt :new Date (). toISOString (), 274}); 275if ( ! Star . validate ( record ). success ) fail ( 400 , "Invalid title or comment" ); 276const uri = await writeRecord ( user . oauth , Star . id , record ); 277app . index . star ( uri , record ); 278retrack ( app , user . did ); 279return redirect ( "/share?shared=1" ); 280} 281 282const TitleRequest = l . object ({ url :l . string }); 283 284async function getTitle ({ req, app, user} :Context ) :Promise < Response > { 285if ( ! user ) fail ( 401 , "Sign in first" ); 286if ( req . headers . get ( "x-csrf-token" ) !== user . csrf ) { 287fail ( 403 , "Invalid form token" ); 288} 289const input = TitleRequest . validate ( await readJson ( req , 4096 )); 290const link = ( input . success && starUrl ( input . value . url )) || 291fail ( 400 , "Invalid link" ); 292return Response . json ( await app . titles . lookup ( user . did , link )); 293} 294 295async function subject ( 296{ app, user} :Signed , 297fields :Fields , 298) :Promise < Actor > { 299const did = fields . subject ; 300if ( ! isDid ( did ) || did === user . did ) fail ( 400 , "Invalid account" ); 301return await app . hydrant . actor ( did ) ?? fail ( 404 , "Account not found" ); 302} 303 304async function follow ( ctx :Signed , fields :Fields ) :Promise < Response > { 305const { app, user} = ctx ; 306const actor = await subject ( ctx , fields ); 307if ( ! app . index . following ( user . did , actor . did ). length ) { 308const record = Follow . build ({ 309subject :actor . did , 310createdAt :new Date (). toISOString (), 311}); 312const uri = await writeRecord ( user . oauth , Follow . id , record ); 313app . index . follow ( uri , record ); 314retrack ( app , user . did ); 315} 316return redirect ( render . profileUrl ( actor )); 317} 318 319async function unfollow ( 320ctx :Signed , 321fields :Fields , 322) :Promise < Response > { 323const { app, user} = ctx ; 324const actor = await subject ( ctx , fields ); 325for ( const uri of app . index . following ( user . did , actor . did )) { 326await deleteRecord ( user . oauth , uri ); 327app . index . removeFollow ( uri ); 328} 329return redirect ( render . profileUrl ( actor )); 330} 331 332async function deleteStar ( 333{ app, user} :Signed , 334fields :Fields , 335) :Promise < Response > { 336const at = parseAtUri ( fields . uri ); 337if ( at ?. did !== user . did || at . collection !== Star . id ) { 338fail ( 403 , "Invalid star" ); 339} 340const uri = atUri ( at ); 341await deleteRecord ( user . oauth , uri ); 342app . index . removeStar ( uri ); 343return redirect ( "/" ); 344} 345 346const routes :Record < string , Route > = { 347"GET /oauth-client-metadata.json" :({ app}) => 348Response . json ( app . auth . client . metadata ), 349"GET /oauth/callback" :oauthCallback , 350"GET /login" :( ctx ) => 351ctx . user ?redirect ( "/" ) :page ( ctx , "Sign in" , render . signInForm ), 352"POST /login" :login , 353"POST /logout" :form ( logout ), 354"GET /go" :go , 355"GET /" :home , 356"GET /share" :sharePage , 357"POST /share" :form ( share ), 358"POST /share/title" :form ( shareTitle ), 359"POST /xrpc/site.hackery.stars.getTitle" :getTitle , 360"POST /follow" :form ( follow ), 361"POST /unfollow" :form ( unfollow ), 362"POST /delete" :form ( deleteStar ), 363}; 364 365export async function handler ( req :Request , app :App ) :Promise < Response > { 366const url = new URL ( req . url ); 367try { 368if ( 369req . method === "POST" && 370req . headers . get ( "origin" ) !== app . auth . origin . origin 371) fail ( 403 , "Invalid origin" ); 372const ctx = { req, url, app, user :await app . auth . session ( req ) }; 373const route = routes [ ` ${ req . method } ${ url . pathname } ` ]; 374if ( route ) return await route ( ctx ); 375const handle = req . method === "GET" && / ^\/([^/]+)$ / . exec ( url . pathname ); 376if ( handle ) return await profile ( ctx , decodeURIComponent ( handle [ 1 ])); 377return fail ( 404 , "Not found" ); 378} catch ( error ) { 379if ( error instanceof HttpError ) return error . response (); 380console . error ( error ); 381return new Response ( "Something went wrong. Please try again later." , { 382status :503 , 383}); 384} 385} 386 387function originFromEnv ( name :string , fallback :string ) :URL { 388const url = new URL ( Deno . env . get ( name ) ?? fallback ); 389if ( ! isBareOrigin ( url )) { 390throw new Error ( ` ${ name } must be a bare http(s) origin` ); 391} 392return url ; 393} 394 395function openDatabase ( path :string ) :DatabaseSync { 396const db = new DatabaseSync ( path ); 397// it holds OAuth tokens 398Deno . chmodSync ( path , 0o600 ); 399db . exec ( "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;" ); 400return db ; 401} 402 403if ( import . meta. main ) { 404const origin = originFromEnv ( "PUBLIC_URL" , "http://127.0.0.1:8000" ); 405const hydrantUrl = originFromEnv ( "HYDRANT_URL" , "http://127.0.0.1:3000" ); 406// plain http is only for local development 407if ( origin . protocol === "http:" && origin . hostname !== "127.0.0.1" ) { 408throw new Error ( "PUBLIC_URL must use https" ); 409} 410const db = openDatabase ( Deno . env . get ( "DATABASE_PATH" ) ?? "./stars.sqlite" ); 411const hydrant = new Hydrant ( hydrantUrl ); 412const app :App = { 413auth :new Auth ( db , origin ), 414 hydrant, 415index :new Index ( db , hydrant ), 416titles :new TitleLookup ( db ), 417}; 418const shutdown = new AbortController (); 419Deno . addSignalListener ( "SIGTERM" , () => shutdown . abort ()); 420app . index . run ( shutdown . signal ); 421const server = Deno . serve ( 422{ 423hostname :"127.0.0.1" , 424port :Number ( origin . port ) || 8000 , 425signal :shutdown . signal , 426}, 427( req ) => handler ( req , app ), 428); 429// stray timers in dependencies shouldn't hold up shutdown; sync writes are atomic or idempotent 430await server . finished ; 431Deno . exit (); 432}