astro-atmosphere-comments 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -23
  3. package/components/AtmosphereComments.astro +179 -78
  4. package/components/AtmosphereReactions.astro +86 -51
  5. package/components/SignInForm.astro +14 -3
  6. package/components/atmosphere.css +21 -0
  7. package/index.mjs +42 -32
  8. package/lib/client/account.ts +553 -0
  9. package/lib/client/composer.ts +7 -10
  10. package/lib/http.ts +28 -0
  11. package/lib/permissions.ts +108 -0
  12. package/lib/public-settings.ts +18 -0
  13. package/lib/thread.ts +36 -6
  14. package/lib/uris.ts +31 -0
  15. package/locales/de.json +20 -4
  16. package/locales/en.json +20 -4
  17. package/locales/es.json +20 -4
  18. package/locales/fr.json +20 -4
  19. package/locales/it.json +20 -4
  20. package/locales/ja.json +20 -4
  21. package/locales/nl.json +20 -4
  22. package/locales/pt.json +20 -4
  23. package/package.json +2 -7
  24. package/routes/callback.astro +42 -0
  25. package/routes/client-metadata.json.ts +3 -2
  26. package/routes/following.ts +28 -0
  27. package/routes/identity.ts +69 -0
  28. package/routes/media.ts +7 -10
  29. package/routes/post.ts +1 -1
  30. package/routes/profile.ts +20 -0
  31. package/routes/richtext.ts +30 -0
  32. package/routes/thread.ts +23 -3
  33. package/routes/typeahead.ts +7 -23
  34. package/virtual.d.ts +5 -0
  35. package/bin/cli.mjs +0 -31
  36. package/lib/oauth.ts +0 -110
  37. package/lib/reader.ts +0 -130
  38. package/lib/store.ts +0 -46
  39. package/lib/visitor.ts +0 -42
  40. package/routes/callback.ts +0 -30
  41. package/routes/comment.ts +0 -97
  42. package/routes/delete.ts +0 -41
  43. package/routes/follow.ts +0 -71
  44. package/routes/jwks.json.ts +0 -13
  45. package/routes/login.ts +0 -58
  46. package/routes/logout.ts +0 -20
  47. package/routes/me.ts +0 -12
  48. package/routes/react.ts +0 -48
  49. package/routes/state.ts +0 -24
package/lib/reader.ts DELETED
@@ -1,130 +0,0 @@
1
- // What every action needs: a request from this site, a signed-in reader, and
2
- // an OAuth session with all the permissions in SCOPE. Readers who signed in
3
- // before the site asked for more (likes and reposts were added later) are
4
- // asked to sign in again rather than meeting a confusing failure.
5
- import type { OAuthSession } from "@atproto/oauth-client-node";
6
- import type { AstroCookies } from "astro";
7
- import { fromThisSite, json } from "./http";
8
- import { oauthClient, SCOPE } from "./oauth";
9
- import settings from "./settings";
10
- import { signedInDid, signOut } from "./visitor";
11
-
12
- export type Reader = { did: string; session: OAuthSession; scopes: string[] };
13
-
14
- const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
15
- const POST_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._~-]+)$/;
16
-
17
- /** The reader, or the Response to send instead. */
18
- export async function reader(request: Request, url: URL, cookies: AstroCookies): Promise<Reader | Response> {
19
- // Requests that change something must come from a page on this site.
20
- // Browsers leave Origin off same-site GETs, and a GET only reads (other
21
- // sites' scripts can't send the SameSite cookie or read the answer).
22
- if (request.method !== "GET") {
23
- if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
24
- if (!request.headers.get("content-type")?.startsWith("application/json")) return json({ error: "bad-request" }, 415);
25
- }
26
- const did = await signedInDid(cookies);
27
- if (!did) return json({ error: "signed-out" }, 401);
28
- try {
29
- const session = await (await oauthClient(url.origin)).restore(did);
30
- // Compared by permission name ("repo:app.bsky.feed.like"), since a host
31
- // may write the parameters after it differently.
32
- const scopes = (await session.getTokenInfo(false)).scope.split(" ");
33
- const granted = scopes.map((part) => part.split("?")[0]);
34
- if (!SCOPE.split(" ").every((part) => granted.includes(part.split("?")[0]))) {
35
- await signOut(cookies);
36
- return json({ error: "new-permissions" }, 401);
37
- }
38
- return { did, session, scopes };
39
- } catch {
40
- // The grant has expired or been revoked: the reader needs to sign in again.
41
- await signOut(cookies);
42
- return json({ error: "signed-out" }, 401);
43
- }
44
- }
45
-
46
- /**
47
- * Whether the reader's grant allows `action` on records of `collection`: a
48
- * repo permission for that collection (or all of them) listing the action,
49
- * or listing no actions, which means all of them.
50
- */
51
- export function allows(reader: Reader, collection: string, action: string): boolean {
52
- return reader.scopes.some((scope) => {
53
- const [name, query = ""] = scope.split("?");
54
- if (name !== `repo:${collection}` && name !== "repo:*") return false;
55
- const actions = new URLSearchParams(query).getAll("action");
56
- return !actions.length || actions.includes(action);
57
- });
58
- }
59
-
60
- /** True for a post by one of the site's own accounts (an announcement). */
61
- export function isSitePost(uri: unknown): uri is string {
62
- const match = typeof uri === "string" ? POST_URI.exec(uri) : null;
63
- return !!match && settings.allowedAuthors.includes(match[1]);
64
- }
65
-
66
- export const isPostUri = (uri: unknown): uri is string => typeof uri === "string" && POST_URI.test(uri);
67
-
68
- /** True for one of the site's announcements, or a comment somewhere under one. */
69
- export const inSiteThread = (view: PostView): boolean =>
70
- isSitePost(view.uri) || isSitePost(view.record?.reply?.root?.uri);
71
-
72
- export type PostView = {
73
- uri: string;
74
- cid: string;
75
- record?: { reply?: { root?: { uri?: string } } };
76
- viewer?: { like?: string; repost?: string };
77
- };
78
-
79
- /** Posts as the reader sees them (including whether they've liked or reposted them). */
80
- export async function postsAsReader(session: OAuthSession, uris: string[]): Promise<PostView[]> {
81
- const views: PostView[] = [];
82
- // Bluesky answers for at most 25 posts at a time.
83
- for (let start = 0; start < uris.length; start += 25) {
84
- const query = uris
85
- .slice(start, start + 25)
86
- .map((uri) => `uris=${encodeURIComponent(uri)}`)
87
- .join("&");
88
- const response = await session.fetchHandler(`/xrpc/app.bsky.feed.getPosts?${query}`, {
89
- headers: { "atproto-proxy": APPVIEW_PROXY },
90
- });
91
- if (!response.ok) throw new Error(`getPosts ${response.status}`);
92
- views.push(...(((await response.json()) as { posts?: PostView[] }).posts ?? []));
93
- }
94
- return views;
95
- }
96
-
97
- export async function createRecord(reader: Reader, collection: string, record: Record<string, unknown>) {
98
- const response = await reader.session.fetchHandler("/xrpc/com.atproto.repo.createRecord", {
99
- method: "POST",
100
- headers: { "content-type": "application/json" },
101
- body: JSON.stringify({ repo: reader.did, collection, record }),
102
- });
103
- if (!response.ok) throw new Error(`createRecord ${response.status} ${await response.text().catch(() => "")}`);
104
- return (await response.json()) as { uri: string; cid: string };
105
- }
106
-
107
- /** Deletes one of the reader's own records, given its AT URI. */
108
- export async function deleteRecord(reader: Reader, uri: string) {
109
- const [, repo, collection, rkey] = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/) ?? [];
110
- if (repo !== reader.did || !collection || !rkey) throw new Error("not the reader's record");
111
- const response = await reader.session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", {
112
- method: "POST",
113
- headers: { "content-type": "application/json" },
114
- body: JSON.stringify({ repo: reader.did, collection, rkey }),
115
- });
116
- if (!response.ok) throw new Error(`deleteRecord ${response.status}`);
117
- }
118
-
119
- /** A simple per-account limit: at most `perMinute` a minute and `perHour` an hour. */
120
- export function rateLimit(perMinute: number, perHour: number) {
121
- const recent = new Map<string, number[]>();
122
- return (did: string): boolean => {
123
- const now = Date.now();
124
- const times = (recent.get(did) ?? []).filter((time) => now - time < 60 * 60 * 1000);
125
- recent.set(did, times);
126
- if (times.length >= perHour || times.filter((time) => now - time < 60 * 1000).length >= perMinute) return true;
127
- times.push(now);
128
- return false;
129
- };
130
- }
package/lib/store.ts DELETED
@@ -1,46 +0,0 @@
1
- // Small file-based stores for sign-in data, one JSON file per key.
2
- //
3
- // They hold OAuth tokens, so the folder and files are readable only by the
4
- // site's own user (0700 / 0600), and file names are hashes, never handles or
5
- // cookie values. Set ATMOSPHERE_DATA_DIR to a folder outside the release, so
6
- // deploys don't sign everyone out (default: .data/atmosphere in the project).
7
- import { createHash } from "node:crypto";
8
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
9
- import path from "node:path";
10
- import { getSecret } from "astro:env/server";
11
-
12
- const root = path.resolve(getSecret("ATMOSPHERE_DATA_DIR") || ".data/atmosphere");
13
-
14
- const fileFor = (folder: string, key: string) =>
15
- path.join(root, folder, `${createHash("sha256").update(key).digest("hex")}.json`);
16
-
17
- type Stored<V> = { value: V; expiresAt?: number };
18
-
19
- export function fileStore<V>(folder: string, lifetimeMs?: number) {
20
- return {
21
- async get(key: string): Promise<V | undefined> {
22
- try {
23
- const stored = JSON.parse(await readFile(fileFor(folder, key), "utf8")) as Stored<V>;
24
- if (stored.expiresAt && stored.expiresAt < Date.now()) {
25
- await rm(fileFor(folder, key), { force: true });
26
- return undefined;
27
- }
28
- return stored.value;
29
- } catch {
30
- return undefined;
31
- }
32
- },
33
- async set(key: string, value: V): Promise<void> {
34
- const file = fileFor(folder, key);
35
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
36
- const stored: Stored<V> = { value, ...(lifetimeMs && { expiresAt: Date.now() + lifetimeMs }) };
37
- // Write then rename, so a crash never leaves half a file.
38
- const temporary = `${file}.${process.pid}.tmp`;
39
- await writeFile(temporary, JSON.stringify(stored), { mode: 0o600 });
40
- await rename(temporary, file);
41
- },
42
- async del(key: string): Promise<void> {
43
- await rm(fileFor(folder, key), { force: true });
44
- },
45
- };
46
- }
package/lib/visitor.ts DELETED
@@ -1,42 +0,0 @@
1
- // Who is signed in on this browser.
2
- //
3
- // The cookie holds only a random value. The server keeps which account that
4
- // value belongs to, so signing out really ends it, and the cookie is useless
5
- // to anyone who copies it after that. HttpOnly keeps it away from page
6
- // scripts; SameSite=Lax lets it come back with the reader from their account
7
- // host (a top-level navigation) but not with other sites' form posts.
8
- import { randomBytes } from "node:crypto";
9
- import type { AstroCookies } from "astro";
10
- import { fileStore } from "./store";
11
-
12
- const COOKIE = "atmosphere_session";
13
- const LIFETIME_MS = 60 * 24 * 60 * 60 * 1000; // 60 days
14
-
15
- type Visitor = { did: string; signedInAt: string };
16
-
17
- const visitors = fileStore<Visitor>("visitors", LIFETIME_MS);
18
-
19
- export async function signIn(cookies: AstroCookies, url: URL, did: string): Promise<void> {
20
- const token = randomBytes(32).toString("base64url");
21
- await visitors.set(token, { did, signedInAt: new Date().toISOString() });
22
- cookies.set(COOKIE, token, {
23
- path: "/",
24
- httpOnly: true,
25
- sameSite: "lax",
26
- secure: url.protocol === "https:",
27
- maxAge: LIFETIME_MS / 1000,
28
- });
29
- }
30
-
31
- /** The signed-in account's DID, if any. */
32
- export async function signedInDid(cookies: AstroCookies): Promise<string | undefined> {
33
- const token = cookies.get(COOKIE)?.value;
34
- if (!token || token.length > 100) return undefined;
35
- return (await visitors.get(token))?.did;
36
- }
37
-
38
- export async function signOut(cookies: AstroCookies): Promise<void> {
39
- const token = cookies.get(COOKIE)?.value;
40
- if (token && token.length <= 100) await visitors.del(token);
41
- cookies.delete(COOKIE, { path: "/" });
42
- }
@@ -1,30 +0,0 @@
1
- // The reader's account host sends them back here after they approve (or
2
- // decline) this site's request. On success they are signed in on this site
3
- // and returned to the post they were reading.
4
- import type { APIRoute } from "astro";
5
- import { safeReturnPath, withStatus } from "../lib/http";
6
- import { oauthClient } from "../lib/oauth";
7
- import { signIn } from "../lib/visitor";
8
-
9
- function returnPathFrom(state: unknown): string {
10
- try {
11
- return safeReturnPath((JSON.parse(String(state)) as { returnTo?: unknown }).returnTo);
12
- } catch {
13
- return "/";
14
- }
15
- }
16
-
17
- export const GET: APIRoute = async ({ url, cookies, redirect }) => {
18
- try {
19
- const client = await oauthClient(url.origin);
20
- const { session, state } = await client.callback(url.searchParams);
21
- await signIn(cookies, url, session.did);
22
- return redirect(withStatus(returnPathFrom(state), "signed-in"), 303);
23
- } catch (error) {
24
- // The reader declined, took too long, or something went wrong. The
25
- // library's error still carries our state, so they can go back.
26
- const state = (error as { state?: unknown }).state;
27
- console.warn("[atmosphere] sign-in failed:", error instanceof Error ? error.message : error);
28
- return redirect(withStatus(returnPathFrom(state), "sign-in-failed"), 303);
29
- }
30
- };
package/routes/comment.ts DELETED
@@ -1,97 +0,0 @@
1
- // Posts as the signed-in reader, to their own account:
2
- // - a comment: a reply to the post's announcement (`root`), or to one of the
3
- // comments under it (`parent`);
4
- // - a quote post (`quote: true`): their own post quoting the announcement.
5
- import { AtpAgent, RichText } from "@atproto/api";
6
- import type { APIRoute } from "astro";
7
- import { json } from "../lib/http";
8
- import { profileFor, publicProfile } from "../lib/profile";
9
- import { createRecord, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
10
- import { forgetThread } from "../lib/thread";
11
-
12
- const MAX_GRAPHEMES = 300; // Bluesky's limit for a post
13
-
14
- const tooMany = rateLimit(5, 30);
15
- const publicAgent = new AtpAgent({ service: "https://public.api.bsky.app" });
16
- const graphemes = new Intl.Segmenter();
17
-
18
- export const POST: APIRoute = async ({ request, url, cookies }) => {
19
- const signedIn = await reader(request, url, cookies);
20
- if (signedIn instanceof Response) return signedIn;
21
-
22
- let body: { text?: unknown; root?: unknown; parent?: unknown; quote?: unknown };
23
- try {
24
- body = await request.json();
25
- } catch {
26
- return json({ error: "bad-request" }, 400);
27
- }
28
-
29
- const text = typeof body.text === "string" ? body.text.trim() : "";
30
- const length = [...graphemes.segment(text)].length;
31
- if (!length) return json({ error: "empty" }, 400);
32
- if (length > MAX_GRAPHEMES) return json({ error: "too-long" }, 400);
33
-
34
- // Only this site's own announcements (and comments under them) can be
35
- // replied to or quoted from here.
36
- const root = body.root;
37
- if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
38
- const quote = body.quote === true;
39
- const parent = !quote && body.parent !== undefined && body.parent !== root ? body.parent : undefined;
40
- if (parent !== undefined && !isPostUri(parent)) return json({ error: "not-allowed" }, 400);
41
-
42
- if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
43
-
44
- let posts;
45
- try {
46
- posts = await postsAsReader(signedIn.session, parent ? [root, parent] : [root]);
47
- } catch {
48
- return json({ error: "unavailable" }, 502);
49
- }
50
- const rootPost = posts.find((post) => post.uri === root);
51
- const parentPost = parent ? posts.find((post) => post.uri === parent) : rootPost;
52
- if (!rootPost || !parentPost) return json({ error: "not-found" }, 404);
53
- // A comment being replied to must belong to this announcement's thread.
54
- if (parent && parentPost.record?.reply?.root?.uri !== root) return json({ error: "not-allowed" }, 400);
55
-
56
- // Links, @mentions and #tags become proper links, as in the Bluesky app.
57
- const richText = new RichText({ text });
58
- try {
59
- await richText.detectFacets(publicAgent);
60
- } catch {
61
- richText.detectFacetsWithoutResolution();
62
- }
63
-
64
- const record = {
65
- $type: "app.bsky.feed.post",
66
- text: richText.text,
67
- ...(richText.facets?.length && { facets: richText.facets }),
68
- ...(quote
69
- ? { embed: { $type: "app.bsky.embed.record", record: { uri: rootPost.uri, cid: rootPost.cid } } }
70
- : {
71
- reply: {
72
- root: { uri: rootPost.uri, cid: rootPost.cid },
73
- parent: { uri: parentPost.uri, cid: parentPost.cid },
74
- },
75
- }),
76
- createdAt: new Date().toISOString(),
77
- };
78
-
79
- let created;
80
- try {
81
- created = await createRecord(signedIn, "app.bsky.feed.post", record);
82
- } catch (error) {
83
- console.warn("[atmosphere] post failed:", error instanceof Error ? error.message : error);
84
- return json({ error: "post-failed" }, 502);
85
- }
86
- forgetThread(root);
87
- const profile = await profileFor(signedIn.did);
88
- return json({
89
- uri: created.uri,
90
- url: `https://bsky.app/profile/${profile.handle}/post/${created.uri.split("/").pop()}`,
91
- text: richText.text,
92
- author: publicProfile(profile),
93
- createdAt: record.createdAt,
94
- quote,
95
- parent: parent ?? null,
96
- });
97
- };
package/routes/delete.ts DELETED
@@ -1,41 +0,0 @@
1
- // Deletes one of the signed-in reader's own comments: a post of theirs that
2
- // is a reply somewhere under one of the site's announcements. Nothing else,
3
- // even though the permission behind it covers all of the reader's posts.
4
- // Body: { post }. Readers who haven't allowed deleting yet get
5
- // "needs-permission", and the page offers to ask their account host for it.
6
- import type { APIRoute } from "astro";
7
- import { json } from "../lib/http";
8
- import { allows, deleteRecord, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
9
- import { forgetThread } from "../lib/thread";
10
-
11
- const tooMany = rateLimit(10, 60);
12
-
13
- export const POST: APIRoute = async ({ request, url, cookies }) => {
14
- const signedIn = await reader(request, url, cookies);
15
- if (signedIn instanceof Response) return signedIn;
16
-
17
- let body: { post?: unknown };
18
- try {
19
- body = await request.json();
20
- } catch {
21
- return json({ error: "bad-request" }, 400);
22
- }
23
- const { post } = body;
24
- if (!isPostUri(post) || !post.startsWith(`at://${signedIn.did}/`)) return json({ error: "not-allowed" }, 400);
25
- if (!allows(signedIn, "app.bsky.feed.post", "delete")) return json({ error: "needs-permission" }, 403);
26
- if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
27
-
28
- try {
29
- const [view] = await postsAsReader(signedIn.session, [post]);
30
- if (!view) return json({ error: "not-found" }, 404);
31
- // Only a reply under one of the site's announcements, never a post of its own.
32
- const root = view.record?.reply?.root?.uri;
33
- if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
34
- await deleteRecord(signedIn, view.uri);
35
- forgetThread(root);
36
- return json({ deleted: view.uri });
37
- } catch (error) {
38
- console.warn("[atmosphere] delete failed:", error instanceof Error ? error.message : error);
39
- return json({ error: "post-failed" }, 502);
40
- }
41
- };
package/routes/follow.ts DELETED
@@ -1,71 +0,0 @@
1
- // Following one of the site's own accounts (allowedAuthors) as the signed-in
2
- // reader, or unfollowing it. Nothing else can be followed through the site.
3
- // GET ?subject=<did>: { following }. POST { subject, on }: follows or
4
- // unfollows. Readers who haven't allowed following yet get "needs-permission",
5
- // and the page sends them to approve it.
6
- import type { APIRoute } from "astro";
7
- import { json } from "../lib/http";
8
- import { allows, createRecord, deleteRecord, rateLimit, reader } from "../lib/reader";
9
- import settings from "../lib/settings";
10
-
11
- const tooMany = rateLimit(10, 60);
12
-
13
- // Whether the reader follows `subject`: the follow record's address, or "".
14
- // From Bluesky's public AppView, so no extra permission is needed to know.
15
- async function followRecord(readerDid: string, subject: string): Promise<string> {
16
- const query = new URLSearchParams({ actor: readerDid, others: subject });
17
- const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getRelationships?${query}`, {
18
- signal: AbortSignal.timeout(5000),
19
- });
20
- if (!response.ok) throw new Error(`getRelationships ${response.status}`);
21
- const data = (await response.json()) as { relationships?: Array<{ did?: string; following?: string }> };
22
- return data.relationships?.find((relationship) => relationship.did === subject)?.following ?? "";
23
- }
24
-
25
- export const GET: APIRoute = async ({ request, url, cookies }) => {
26
- const subject = url.searchParams.get("subject") ?? "";
27
- if (!settings.allowedAuthors.includes(subject)) return json({ error: "not-allowed" }, 400);
28
- const signedIn = await reader(request, url, cookies);
29
- if (signedIn instanceof Response) return signedIn;
30
- try {
31
- return json({ following: !!(await followRecord(signedIn.did, subject)) });
32
- } catch {
33
- return json({ error: "unavailable" }, 502);
34
- }
35
- };
36
-
37
- export const POST: APIRoute = async ({ request, url, cookies }) => {
38
- const signedIn = await reader(request, url, cookies);
39
- if (signedIn instanceof Response) return signedIn;
40
-
41
- let body: { subject?: unknown; on?: unknown };
42
- try {
43
- body = await request.json();
44
- } catch {
45
- return json({ error: "bad-request" }, 400);
46
- }
47
- const { subject, on } = body;
48
- // Only the site's own accounts, and never yourself.
49
- if (typeof subject !== "string" || !settings.allowedAuthors.includes(subject) || subject === signedIn.did || typeof on !== "boolean") {
50
- return json({ error: "not-allowed" }, 400);
51
- }
52
- if (!allows(signedIn, "app.bsky.graph.follow", on ? "create" : "delete")) return json({ error: "needs-permission" }, 403);
53
- if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
54
-
55
- try {
56
- const existing = await followRecord(signedIn.did, subject);
57
- if (on && !existing) {
58
- await createRecord(signedIn, "app.bsky.graph.follow", {
59
- $type: "app.bsky.graph.follow",
60
- subject,
61
- createdAt: new Date().toISOString(),
62
- });
63
- } else if (!on && existing) {
64
- await deleteRecord(signedIn, existing);
65
- }
66
- return json({ following: on });
67
- } catch (error) {
68
- console.warn("[atmosphere] follow failed:", error instanceof Error ? error.message : error);
69
- return json({ error: "post-failed" }, 502);
70
- }
71
- };
@@ -1,13 +0,0 @@
1
- // The public half of the site's signing key, so account hosts can check that
2
- // requests claiming to come from this site really do.
3
- import type { APIRoute } from "astro";
4
- import { privateKey } from "../lib/oauth";
5
-
6
- export const GET: APIRoute = async () => {
7
- try {
8
- const key = await privateKey();
9
- return Response.json({ keys: [key.publicJwk] }, { headers: { "Cache-Control": "public, max-age=600" } });
10
- } catch {
11
- return new Response("Not configured", { status: 404 });
12
- }
13
- };
package/routes/login.ts DELETED
@@ -1,58 +0,0 @@
1
- // Starts signing in: the reader typed their handle (or DID) into the comment
2
- // box. Their own account host is found from it, and they are sent there to
3
- // approve this site's request to post comments for them.
4
- import type { APIRoute } from "astro";
5
- import { safeReturnPath, withStatus } from "../lib/http";
6
- import { DELETE_SCOPE, FOLLOW_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
7
- import { signedInDid } from "../lib/visitor";
8
-
9
- // The extra permissions a reader can add: deleting their comments, following.
10
- const EXTRA_SCOPES: Record<string, string> = { delete: DELETE_SCOPE, follow: FOLLOW_SCOPE };
11
-
12
- export const POST: APIRoute = async ({ request, url, redirect, cookies }) => {
13
- const form = await request.formData();
14
- const returnTo = safeReturnPath(form.get("returnTo"));
15
- const handle = String(form.get("handle") ?? "")
16
- .trim()
17
- .replace(/^@/, "")
18
- .toLowerCase();
19
- if (!handle || handle.length > 253 || !/^(did:[a-z0-9:._%-]+|[a-z0-9.-]+\.[a-z0-9-]+)$/.test(handle)) {
20
- return redirect(withStatus(returnTo, "invalid-handle"), 303);
21
- }
22
-
23
- try {
24
- const client = await oauthClient(url.origin);
25
- // A new approval replaces the old one, so keep any extra permission the
26
- // reader already gave (asking to follow mustn't take away deleting).
27
- const extras = new Set<string>();
28
- const wanted = String(form.get("permission") ?? "");
29
- if (EXTRA_SCOPES[wanted]) extras.add(EXTRA_SCOPES[wanted]);
30
- const did = await signedInDid(cookies);
31
- if (did) {
32
- try {
33
- const granted = (await (await client.restore(did, false)).getTokenInfo(false)).scope.split(" ").map((scope) => {
34
- const [name, query = ""] = scope.split("?");
35
- return { name, actions: new URLSearchParams(query).getAll("action") };
36
- });
37
- // As in allows() (lib/reader.ts): no actions listed means all of them.
38
- const has = (collection: string, action: string) =>
39
- granted.some(({ name, actions }) => (name === `repo:${collection}` || name === "repo:*") && (!actions.length || actions.includes(action)));
40
- if (has("app.bsky.feed.post", "delete")) extras.add(DELETE_SCOPE);
41
- if (has("app.bsky.graph.follow", "create")) extras.add(FOLLOW_SCOPE);
42
- } catch {
43
- // Not signed in any more: just what's asked for.
44
- }
45
- }
46
- const authorizeUrl = await client.authorize(handle, {
47
- // The page sends readers back here with permission=delete or =follow
48
- // the first time they delete a comment or follow, if they haven't
49
- // allowed that yet.
50
- scope: [SCOPE, ...extras].join(" "),
51
- state: JSON.stringify({ returnTo }),
52
- });
53
- return redirect(authorizeUrl.href, 303);
54
- } catch (error) {
55
- console.warn("[atmosphere] sign-in could not start:", error instanceof Error ? error.message : error);
56
- return redirect(withStatus(returnTo, "account-not-found"), 303);
57
- }
58
- };
package/routes/logout.ts DELETED
@@ -1,20 +0,0 @@
1
- // Signs the reader out on this site and asks their host to cancel this
2
- // site's permission to post for them.
3
- import type { APIRoute } from "astro";
4
- import { fromThisSite, json } from "../lib/http";
5
- import { oauthClient } from "../lib/oauth";
6
- import { signedInDid, signOut } from "../lib/visitor";
7
-
8
- export const POST: APIRoute = async ({ request, url, cookies }) => {
9
- if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
10
- const did = await signedInDid(cookies);
11
- await signOut(cookies);
12
- if (did) {
13
- try {
14
- await (await oauthClient(url.origin)).revoke(did);
15
- } catch (error) {
16
- console.warn("[atmosphere] revoke failed:", error instanceof Error ? error.message : error);
17
- }
18
- }
19
- return json({ signedIn: false });
20
- };
package/routes/me.ts DELETED
@@ -1,12 +0,0 @@
1
- // Who is signed in on this browser, for the comment box.
2
- import type { APIRoute } from "astro";
3
- import { json } from "../lib/http";
4
- import { profileFor, publicProfile } from "../lib/profile";
5
- import { signedInDid } from "../lib/visitor";
6
-
7
- export const GET: APIRoute = async ({ cookies }) => {
8
- const did = await signedInDid(cookies);
9
- if (!did) return json({ signedIn: false });
10
- const profile = await profileFor(did);
11
- return json({ signedIn: true, ...publicProfile(profile) });
12
- };
package/routes/react.ts DELETED
@@ -1,48 +0,0 @@
1
- // Likes or reposts a post (the site's announcement, or a comment under it) as
2
- // the signed-in reader, or takes that back.
3
- // Body: { post, kind: "like" | "repost", on: boolean }.
4
- import type { APIRoute } from "astro";
5
- import { json } from "../lib/http";
6
- import { createRecord, deleteRecord, inSiteThread, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
7
- import { forgetThread } from "../lib/thread";
8
-
9
- const tooMany = rateLimit(20, 120);
10
- const COLLECTIONS = { like: "app.bsky.feed.like", repost: "app.bsky.feed.repost" } as const;
11
-
12
- export const POST: APIRoute = async ({ request, url, cookies }) => {
13
- const signedIn = await reader(request, url, cookies);
14
- if (signedIn instanceof Response) return signedIn;
15
-
16
- let body: { post?: unknown; kind?: unknown; on?: unknown };
17
- try {
18
- body = await request.json();
19
- } catch {
20
- return json({ error: "bad-request" }, 400);
21
- }
22
- const { post, kind, on } = body;
23
- if (!isPostUri(post) || (kind !== "like" && kind !== "repost") || typeof on !== "boolean") {
24
- return json({ error: "bad-request" }, 400);
25
- }
26
- if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
27
-
28
- try {
29
- const [view] = await postsAsReader(signedIn.session, [post]);
30
- if (!view) return json({ error: "not-found" }, 404);
31
- if (!inSiteThread(view)) return json({ error: "not-allowed" }, 400);
32
- const existing = kind === "like" ? view.viewer?.like : view.viewer?.repost;
33
- if (on && !existing) {
34
- await createRecord(signedIn, COLLECTIONS[kind], {
35
- $type: COLLECTIONS[kind],
36
- subject: { uri: view.uri, cid: view.cid },
37
- createdAt: new Date().toISOString(),
38
- });
39
- } else if (!on && existing) {
40
- await deleteRecord(signedIn, existing);
41
- }
42
- forgetThread(isSitePost(view.uri) ? view.uri : (view.record?.reply?.root?.uri ?? ""));
43
- return json({ kind, on });
44
- } catch (error) {
45
- console.warn(`[atmosphere] ${kind} failed:`, error instanceof Error ? error.message : error);
46
- return json({ error: "post-failed" }, 502);
47
- }
48
- };
package/routes/state.ts DELETED
@@ -1,24 +0,0 @@
1
- // Whether the signed-in reader has liked or reposted the announcement and the
2
- // comments under it, so the buttons start in the right state.
3
- // ?post=<uri>&post=<uri>… (at most 100); posts outside the site's threads
4
- // are left out of the answer.
5
- import type { APIRoute } from "astro";
6
- import { json } from "../lib/http";
7
- import { inSiteThread, isPostUri, postsAsReader, reader } from "../lib/reader";
8
-
9
- export const GET: APIRoute = async ({ request, url, cookies }) => {
10
- const uris = [...new Set(url.searchParams.getAll("post"))].filter(isPostUri).slice(0, 100);
11
- if (!uris.length) return json({ error: "bad-request" }, 400);
12
- const signedIn = await reader(request, url, cookies);
13
- if (signedIn instanceof Response) return signedIn;
14
- try {
15
- const views = await postsAsReader(signedIn.session, uris);
16
- const posts: Record<string, { liked: boolean; reposted: boolean }> = {};
17
- for (const view of views) {
18
- if (inSiteThread(view)) posts[view.uri] = { liked: !!view.viewer?.like, reposted: !!view.viewer?.repost };
19
- }
20
- return json({ posts });
21
- } catch {
22
- return json({ error: "unavailable" }, 502);
23
- }
24
- };