astro-atmosphere-comments 0.4.2 → 0.6.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.
- package/CHANGELOG.md +40 -0
- package/README.md +36 -27
- package/components/AtmosphereComments.astro +378 -117
- package/components/AtmosphereReactions.astro +145 -98
- package/components/Composer.astro +5 -2
- package/components/SignInForm.astro +65 -7
- package/components/atmosphere.css +267 -2
- package/index.d.ts +11 -0
- package/index.mjs +53 -32
- package/lib/client/account.ts +661 -0
- package/lib/client/composer.ts +23 -10
- package/lib/client/subscribe.ts +106 -0
- package/lib/http.ts +28 -0
- package/lib/permissions.ts +132 -0
- package/lib/public-settings.ts +20 -0
- package/lib/quoted.ts +4 -0
- package/lib/settings.ts +2 -0
- package/lib/thread.ts +36 -6
- package/lib/uris.ts +39 -0
- package/locales/de.json +20 -4
- package/locales/en.json +48 -13
- package/locales/es.json +20 -4
- package/locales/fr.json +20 -4
- package/locales/it.json +20 -4
- package/locales/ja.json +20 -4
- package/locales/nl.json +20 -4
- package/locales/pt.json +20 -4
- package/package.json +2 -7
- package/routes/callback.astro +42 -0
- package/routes/client-metadata.json.ts +3 -2
- package/routes/following.ts +28 -0
- package/routes/identity.ts +69 -0
- package/routes/media.ts +7 -10
- package/routes/post.ts +2 -1
- package/routes/profile.ts +20 -0
- package/routes/richtext.ts +30 -0
- package/routes/thread.ts +23 -3
- package/routes/typeahead.ts +7 -23
- package/virtual.d.ts +5 -0
- package/bin/cli.mjs +0 -31
- package/lib/oauth.ts +0 -110
- package/lib/reader.ts +0 -130
- package/lib/store.ts +0 -46
- package/lib/visitor.ts +0 -42
- package/routes/callback.ts +0 -30
- package/routes/comment.ts +0 -97
- package/routes/delete.ts +0 -41
- package/routes/follow.ts +0 -71
- package/routes/jwks.json.ts +0 -13
- package/routes/login.ts +0 -58
- package/routes/logout.ts +0 -20
- package/routes/me.ts +0 -12
- package/routes/react.ts +0 -48
- package/routes/state.ts +0 -24
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
|
-
};
|
package/routes/jwks.json.ts
DELETED
|
@@ -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
|
-
};
|