import { DatabaseSync } from "node:sqlite"; import * as l from "@cerulea/lexicon"; import { Auth, deleteRecord, scope, type Session, writeRecord, } from "./auth.ts"; import { cookie, fail, HttpError, isBareOrigin, readBytes, readCookie, redirect, } from "./http.ts"; import { type Actor, Hydrant } from "./hydrant.ts"; import { atUri, type Did, Follow, isDid, parseAtUri, Star, starUrl, } from "./records.ts"; import * as render from "./render.ts"; import { Index, type IndexedStar } from "./sync.ts"; import { TitleLookup } from "./title.ts"; export interface App { auth: Auth; hydrant: Hydrant; index: Index; titles: TitleLookup; } type Context = { req: Request; url: URL; app: App; user?: Session }; type Signed = Context & { user: Session }; type Route = (ctx: Context) => Response | Promise; type Handle = `${string}.${string}`; type Fields = Partial>; const isHandle = (value: string): value is Handle => l.handle.validate(value).success; const isTid = (value: string) => l.tid.validate(value).success; // What a user may type to name an account. function identifier(raw: string | undefined): Handle | Did | undefined { const value = raw?.trim().replace(/^@/, "") ?? ""; if (isDid(value) || isHandle(value)) return value; } const mediaType = (req: Request) => req.headers.get("content-type")?.split(";")[0]; async function bodyText(req: Request, maxBytes: number): Promise { if (Number(req.headers.get("content-length")) > maxBytes) { fail(413, "Request body too large"); } const body = req.body ?? fail(400, "Missing request body"); return new TextDecoder().decode(await readBytes(body, maxBytes)); } async function readForm(req: Request): Promise { if (mediaType(req) !== "application/x-www-form-urlencoded") { fail(415, "Unsupported form encoding"); } return Object.fromEntries( new URLSearchParams(await bodyText(req, 1_048_576)), ); } async function readJson(req: Request, maxBytes: number): Promise { if (mediaType(req) !== "application/json") fail(415, "Expected JSON"); const text = await bodyText(req, maxBytes); try { return JSON.parse(text); } catch { fail(400, "Invalid JSON"); } } // Routes for signed-in users posting a form that carries their CSRF token. function form( route: (ctx: Signed, fields: Fields) => Response | Promise, ): Route { return async (ctx) => { const user = ctx.user ?? fail(401, "Sign in first"); const fields = await readForm(ctx.req); if (fields.csrf !== user.csrf) fail(403, "Invalid form token"); return route({ ...ctx, user }, fields); }; } async function page( { app, user }: Context, title: string, body: string, actor?: Actor, ): Promise { if (!user) return render.page(title, body); const self = actor?.did === user.did ? actor : await app.hydrant.actor(user.did).catch(() => undefined); return render.page(title, body, { handle: self?.handle ?? user.did, url: self ? render.profileUrl(self) : render.profilePath(user.did), }); } async function login({ req, app }: Context): Promise { const fields = await readForm(req); const handle = identifier(fields.handle) ?? fail(400, "Enter an ATProto handle or DID"); const { url, stateId } = await app.auth.client.authorize({ target: { type: "account", identifier: handle }, scope, }); return redirect( url.toString(), cookie("stars_oauth_flow", stateId, app.auth.secure, 600), ); } async function oauthCallback({ req, url, app }: Context): Promise { const flow = readCookie(req, "stars_oauth_flow"); if (!flow || flow !== url.searchParams.get("state")) { fail(403, "Invalid login state"); } const { session } = await app.auth.client.callback(url.searchParams); await app.index.ensureViewer(session.did); return redirect( "/", app.auth.sessionCookie(app.auth.createBrowser(session.did)), cookie("stars_oauth_flow", "", app.auth.secure, 0), ); } function logout({ app, user }: Signed): Response { app.auth.deleteBrowser(user.id); return redirect("/", app.auth.sessionCookie("", 0)); } function go({ url }: Context): Response { const handle = identifier(url.searchParams.get("handle") ?? undefined) ?? fail(400, "Invalid identifier"); return redirect(render.profilePath(handle)); } // The index only holds URIs; the records and their authors come from Hydrant. async function hydrate(hydrant: Hydrant, rows: IndexedStar[]) { const authors = [...new Set(rows.map((row) => row.author))]; const resolved = await Promise.all(authors.map((did) => hydrant.actor(did))); const actors = new Map(authors.map((did, i) => [did, resolved[i]])); const entries = await Promise.all(rows.map((row) => hydrant.star(row.uri))); return rows.flatMap((row, i) => { const actor = actors.get(row.author), entry = entries[i]; return actor && entry ? [{ entry, actor }] : []; }); } async function home(ctx: Context): Promise { const { app, url, user } = ctx; if (!user) return page(ctx, "Home", render.welcome); const before = url.searchParams.get("before") ?? undefined; if (before && parseAtUri(before)?.collection !== Star.id) { fail(400, "Invalid cursor"); } const viewerReady = await app.index.ensureViewer(user.did); const { stars, next } = app.index.page(user.did, before); const hydrated = await hydrate(app.hydrant, stars); const notice = app.index.ready && app.index.connected && viewerReady ? "" : render.notice( "The feed is catching up with Hydrant or temporarily disconnected.", ); const body = render.feed( user.csrf, notice + render.list( hydrated.map(({ entry, actor }) => render.star(entry, actor)), "No stars yet. Follow someone to see their links here.", next && `/?before=${encodeURIComponent(next)}`, ), ); const self = hydrated.find(({ actor }) => actor.did === user.did)?.actor; return page(ctx, "Your feed", body, self); } async function profile(ctx: Context, raw: string): Promise { const { app, url, user } = ctx; const handle = identifier(raw) ?? fail(404, "Not found"); const actor = await app.hydrant.actor(handle) ?? fail(404, "Not found"); const before = url.searchParams.get("before") ?? undefined; if (before && !isTid(before)) fail(400, "Invalid cursor"); const { stars, cursor } = await app.hydrant.stars(actor.did, before); const follow = user && user.did !== actor.did ? render.followForm( user.csrf, actor, app.index.following(user.did, actor.did).length > 0, ) : ""; const csrf = user?.did === actor.did ? user.csrf : undefined; const body = render.profile( actor, follow, render.list( stars.map((entry) => render.star(entry, actor, csrf)), "No stars yet.", cursor && `${render.profilePath(handle)}?before=${encodeURIComponent(cursor)}`, ), ); return page(ctx, actor.handle, body, actor); } function sharePage(ctx: Context): Response | Promise { const { url, user } = ctx; if (!user) return redirect("/login"); return page( ctx, "Share a star", render.shareForm(user.csrf, { url: (url.searchParams.get("url") ?? "").slice(0, 2048), notice: url.searchParams.has("shared") ? "Starred! Hydrant may take a moment to show your new star." : undefined, }), ); } async function shareTitle( ctx: Signed, fields: Fields, ): Promise { const { app, user } = ctx; const link = starUrl(fields.url) ?? fail(400, "Invalid link"); const preview = await app.titles.lookup(user.did, link); return page( ctx, "Share a star", render.shareForm(user.csrf, { url: link, title: preview.title, comment: fields.comment, notice: preview.source === "url" ? "No page title found. You can edit the URL used as its title." : undefined, }), ); } // Hydrant may have dropped a repo while it was empty, so re-track after writes. function retrack(app: App, did: Did): void { app.hydrant.track(did).catch((error) => console.error("Could not track repo", did, error) ); } async function share( { app, user }: Signed, fields: Fields, ): Promise { const link = starUrl(fields.url) ?? fail(400, "Invalid link"); const record = Star.build({ url: link, title: fields.title?.trim() || link, comment: fields.comment || undefined, createdAt: new Date().toISOString(), }); if (!Star.validate(record).success) fail(400, "Invalid title or comment"); const uri = await writeRecord(user.oauth, Star.id, record); app.index.star(uri, record); retrack(app, user.did); return redirect("/share?shared=1"); } const TitleRequest = l.object({ url: l.string }); async function getTitle({ req, app, user }: Context): Promise { if (!user) fail(401, "Sign in first"); if (req.headers.get("x-csrf-token") !== user.csrf) { fail(403, "Invalid form token"); } const input = TitleRequest.validate(await readJson(req, 4096)); const link = (input.success && starUrl(input.value.url)) || fail(400, "Invalid link"); return Response.json(await app.titles.lookup(user.did, link)); } async function subject( { app, user }: Signed, fields: Fields, ): Promise { const did = fields.subject; if (!isDid(did) || did === user.did) fail(400, "Invalid account"); return await app.hydrant.actor(did) ?? fail(404, "Account not found"); } async function follow(ctx: Signed, fields: Fields): Promise { const { app, user } = ctx; const actor = await subject(ctx, fields); if (!app.index.following(user.did, actor.did).length) { const record = Follow.build({ subject: actor.did, createdAt: new Date().toISOString(), }); const uri = await writeRecord(user.oauth, Follow.id, record); app.index.follow(uri, record); retrack(app, user.did); } return redirect(render.profileUrl(actor)); } async function unfollow( ctx: Signed, fields: Fields, ): Promise { const { app, user } = ctx; const actor = await subject(ctx, fields); for (const uri of app.index.following(user.did, actor.did)) { await deleteRecord(user.oauth, uri); app.index.removeFollow(uri); } return redirect(render.profileUrl(actor)); } async function deleteStar( { app, user }: Signed, fields: Fields, ): Promise { const at = parseAtUri(fields.uri); if (at?.did !== user.did || at.collection !== Star.id) { fail(403, "Invalid star"); } const uri = atUri(at); await deleteRecord(user.oauth, uri); app.index.removeStar(uri); return redirect("/"); } const routes: Record = { "GET /oauth-client-metadata.json": ({ app }) => Response.json(app.auth.client.metadata), "GET /oauth/callback": oauthCallback, "GET /login": (ctx) => ctx.user ? redirect("/") : page(ctx, "Sign in", render.signInForm), "POST /login": login, "POST /logout": form(logout), "GET /go": go, "GET /": home, "GET /share": sharePage, "POST /share": form(share), "POST /share/title": form(shareTitle), "POST /xrpc/site.hackery.stars.getTitle": getTitle, "POST /follow": form(follow), "POST /unfollow": form(unfollow), "POST /delete": form(deleteStar), }; export async function handler(req: Request, app: App): Promise { const url = new URL(req.url); try { if ( req.method === "POST" && req.headers.get("origin") !== app.auth.origin.origin ) fail(403, "Invalid origin"); const ctx = { req, url, app, user: await app.auth.session(req) }; const route = routes[`${req.method} ${url.pathname}`]; if (route) return await route(ctx); const handle = req.method === "GET" && /^\/([^/]+)$/.exec(url.pathname); if (handle) return await profile(ctx, decodeURIComponent(handle[1])); return fail(404, "Not found"); } catch (error) { if (error instanceof HttpError) return error.response(); console.error(error); return new Response("Something went wrong. Please try again later.", { status: 503, }); } } function originFromEnv(name: string, fallback: string): URL { const url = new URL(Deno.env.get(name) ?? fallback); if (!isBareOrigin(url)) { throw new Error(`${name} must be a bare http(s) origin`); } return url; } function openDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path); // it holds OAuth tokens Deno.chmodSync(path, 0o600); db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;"); return db; } if (import.meta.main) { const origin = originFromEnv("PUBLIC_URL", "http://127.0.0.1:8000"); const hydrantUrl = originFromEnv("HYDRANT_URL", "http://127.0.0.1:3000"); // plain http is only for local development if (origin.protocol === "http:" && origin.hostname !== "127.0.0.1") { throw new Error("PUBLIC_URL must use https"); } const db = openDatabase(Deno.env.get("DATABASE_PATH") ?? "./stars.sqlite"); const hydrant = new Hydrant(hydrantUrl); const app: App = { auth: new Auth(db, origin), hydrant, index: new Index(db, hydrant), titles: new TitleLookup(db), }; const shutdown = new AbortController(); Deno.addSignalListener("SIGTERM", () => shutdown.abort()); app.index.run(shutdown.signal); const server = Deno.serve( { hostname: "127.0.0.1", port: Number(origin.port) || 8000, signal: shutdown.signal, }, (req) => handler(req, app), ); // stray timers in dependencies shouldn't hold up shutdown; sync writes are atomic or idempotent await server.finished; Deno.exit(); }