astro-atmosphere-comments 0.2.0 → 0.4.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 +17 -0
- package/README.md +59 -3
- package/components/AtmosphereComments.astro +120 -1087
- package/components/AtmosphereReactions.astro +785 -0
- package/components/Composer.astro +80 -0
- package/components/SignInForm.astro +218 -0
- package/components/atmosphere.css +603 -0
- package/index.d.ts +11 -0
- package/index.mjs +3 -0
- package/lib/client/composer.ts +232 -0
- package/lib/client/handle-suggestions.ts +112 -0
- package/lib/i18n.ts +32 -0
- package/lib/oauth.ts +5 -1
- package/lib/settings.ts +2 -0
- package/lib/translate.ts +36 -0
- package/locales/de.json +95 -0
- package/locales/en.json +95 -0
- package/locales/es.json +98 -0
- package/locales/fr.json +98 -0
- package/locales/it.json +98 -0
- package/locales/ja.json +92 -0
- package/locales/nl.json +95 -0
- package/locales/pt.json +98 -0
- package/package.json +8 -3
- package/routes/follow.ts +71 -0
- package/routes/login.ts +31 -5
package/routes/follow.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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/login.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// approve this site's request to post comments for them.
|
|
4
4
|
import type { APIRoute } from "astro";
|
|
5
5
|
import { safeReturnPath, withStatus } from "../lib/http";
|
|
6
|
-
import { DELETE_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
|
|
6
|
+
import { DELETE_SCOPE, FOLLOW_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
|
|
7
|
+
import { signedInDid } from "../lib/visitor";
|
|
7
8
|
|
|
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 }) => {
|
|
9
13
|
const form = await request.formData();
|
|
10
14
|
const returnTo = safeReturnPath(form.get("returnTo"));
|
|
11
15
|
const handle = String(form.get("handle") ?? "")
|
|
@@ -18,10 +22,32 @@ export const POST: APIRoute = async ({ request, url, redirect }) => {
|
|
|
18
22
|
|
|
19
23
|
try {
|
|
20
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
|
+
}
|
|
21
46
|
const authorizeUrl = await client.authorize(handle, {
|
|
22
|
-
// The
|
|
23
|
-
//
|
|
24
|
-
|
|
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(" "),
|
|
25
51
|
state: JSON.stringify({ returnTo }),
|
|
26
52
|
});
|
|
27
53
|
return redirect(authorizeUrl.href, 303);
|