char-slop/stars.hackery.site

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

Charlotte Somuse @cerulea/lexicon 0.2.0 and its typed format schemas8463413

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