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/post.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { APIRoute } from "astro";
|
|
|
5
5
|
import { json } from "../lib/http";
|
|
6
6
|
import { publicProfile } from "../lib/profile";
|
|
7
7
|
import { quotedPost } from "../lib/quoted";
|
|
8
|
-
import { isSitePost } from "../lib/
|
|
8
|
+
import { isSitePost } from "../lib/uris";
|
|
9
9
|
import settings from "../lib/settings";
|
|
10
10
|
|
|
11
11
|
export const GET: APIRoute = async ({ url }) => {
|
|
@@ -18,6 +18,7 @@ export const GET: APIRoute = async ({ url }) => {
|
|
|
18
18
|
return json({
|
|
19
19
|
author: publicProfile(quoted.author),
|
|
20
20
|
text: quoted.text,
|
|
21
|
+
indexedAt: quoted.indexedAt,
|
|
21
22
|
...(external && {
|
|
22
23
|
external: {
|
|
23
24
|
uri: external.uri,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A signed-in reader's public profile, for the comment box and the account
|
|
2
|
+
// menu: ?did=<DID> gives their handle, display name and avatar (through this
|
|
3
|
+
// site, routes/media.ts). The page asks for it after the reader signs in in
|
|
4
|
+
// their own browser; it's public, so no sign-in is needed to ask.
|
|
5
|
+
import type { APIRoute } from "astro";
|
|
6
|
+
import { rememberAvatar } from "../lib/avatars";
|
|
7
|
+
import { addressOf, json, rateLimit } from "../lib/http";
|
|
8
|
+
import { profileFor, publicProfile } from "../lib/profile";
|
|
9
|
+
|
|
10
|
+
const DID = /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/;
|
|
11
|
+
const perAddress = rateLimit(30, 300);
|
|
12
|
+
|
|
13
|
+
export const GET: APIRoute = async (context) => {
|
|
14
|
+
const did = context.url.searchParams.get("did") ?? "";
|
|
15
|
+
if (!DID.test(did) || did.length > 200) return json({ error: "bad-request" }, 400);
|
|
16
|
+
if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
|
|
17
|
+
const profile = await profileFor(did);
|
|
18
|
+
if (profile.avatarSource) rememberAvatar(did, profile.avatarSource);
|
|
19
|
+
return json(publicProfile(profile));
|
|
20
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Finds the links, @mentions and #hashtags in a comment before the reader's
|
|
2
|
+
// browser posts it, as the Bluesky app does: POST { text } gives { text,
|
|
3
|
+
// facets }. Mentions are looked up here (Bluesky's public API), so the
|
|
4
|
+
// reader's browser doesn't contact Bluesky. Nothing is posted or kept.
|
|
5
|
+
import { AtpAgent, RichText } from "@atproto/api";
|
|
6
|
+
import type { APIRoute } from "astro";
|
|
7
|
+
import { addressOf, fromThisSite, json, rateLimit } from "../lib/http";
|
|
8
|
+
|
|
9
|
+
const publicAgent = new AtpAgent({ service: "https://public.api.bsky.app" });
|
|
10
|
+
const perAddress = rateLimit(20, 200);
|
|
11
|
+
|
|
12
|
+
export const POST: APIRoute = async (context) => {
|
|
13
|
+
const { request, url } = context;
|
|
14
|
+
if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
|
|
15
|
+
let text: unknown;
|
|
16
|
+
try {
|
|
17
|
+
({ text } = await request.json());
|
|
18
|
+
} catch {
|
|
19
|
+
return json({ error: "bad-request" }, 400);
|
|
20
|
+
}
|
|
21
|
+
if (typeof text !== "string" || !text.trim() || text.length > 3000) return json({ error: "bad-request" }, 400);
|
|
22
|
+
if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
|
|
23
|
+
const richText = new RichText({ text: text.trim() });
|
|
24
|
+
try {
|
|
25
|
+
await richText.detectFacets(publicAgent);
|
|
26
|
+
} catch {
|
|
27
|
+
richText.detectFacetsWithoutResolution();
|
|
28
|
+
}
|
|
29
|
+
return json({ text: richText.text, facets: richText.facets ?? [] });
|
|
30
|
+
};
|
package/routes/thread.ts
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
// ?document=<at:// URI of the page's Standard.site record>, whose
|
|
4
4
|
// bskyPostRef names the announcement.
|
|
5
5
|
// Only threads started by the site's own accounts are served.
|
|
6
|
+
//
|
|
7
|
+
// POST { root } forgets the cached copy of a thread, so a comment the
|
|
8
|
+
// reader's browser has just posted shows when the page is next loaded.
|
|
6
9
|
import type { APIRoute } from "astro";
|
|
7
|
-
import { json } from "../lib/http";
|
|
8
|
-
import { isSitePost } from "../lib/
|
|
10
|
+
import { addressOf, fromThisSite, json, rateLimit } from "../lib/http";
|
|
11
|
+
import { isSitePost } from "../lib/uris";
|
|
9
12
|
import { announcementFromDocument, postUriFromUrl } from "../lib/resolve";
|
|
10
|
-
import { getThread } from "../lib/thread";
|
|
13
|
+
import { forgetThread, getThread } from "../lib/thread";
|
|
14
|
+
|
|
15
|
+
const perAddress = rateLimit(10, 100);
|
|
11
16
|
|
|
12
17
|
export const GET: APIRoute = async ({ url }) => {
|
|
13
18
|
const post = url.searchParams.get("post");
|
|
@@ -25,3 +30,18 @@ export const GET: APIRoute = async ({ url }) => {
|
|
|
25
30
|
const thread = await getThread(uri);
|
|
26
31
|
return thread ? json({ thread }) : json({ error: "unavailable" }, 502);
|
|
27
32
|
};
|
|
33
|
+
|
|
34
|
+
export const POST: APIRoute = async (context) => {
|
|
35
|
+
const { request, url } = context;
|
|
36
|
+
if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
|
|
37
|
+
let root: unknown;
|
|
38
|
+
try {
|
|
39
|
+
({ root } = await request.json());
|
|
40
|
+
} catch {
|
|
41
|
+
return json({ error: "bad-request" }, 400);
|
|
42
|
+
}
|
|
43
|
+
if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
|
|
44
|
+
if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
|
|
45
|
+
forgetThread(root);
|
|
46
|
+
return json({ ok: true });
|
|
47
|
+
};
|
package/routes/typeahead.ts
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
// proxy every visitor may share one address), so the site can't be used to
|
|
7
7
|
// flood Bluesky's search. Answers are remembered for a minute.
|
|
8
8
|
import type { APIRoute } from "astro";
|
|
9
|
-
import { json } from "../lib/http";
|
|
10
|
-
import { rateLimit } from "../lib/reader";
|
|
9
|
+
import { addressOf, json, rateLimit } from "../lib/http";
|
|
11
10
|
import settings from "../lib/settings";
|
|
12
11
|
import { rememberAvatar } from "../lib/avatars";
|
|
13
|
-
import { signedInDid } from "../lib/visitor";
|
|
14
12
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const signedOutOverall = rateLimit(300, 6000);
|
|
13
|
+
const perAddress = rateLimit(60, 600);
|
|
14
|
+
const overall = rateLimit(600, 12000);
|
|
18
15
|
|
|
19
16
|
type Actor = { did: string; handle: string; displayName?: string; avatar?: string };
|
|
20
17
|
const recent = new Map<string, { actors: Actor[]; at: number }>();
|
|
@@ -36,23 +33,10 @@ async function search(q: string): Promise<Actor[]> {
|
|
|
36
33
|
return actors;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
export const GET: APIRoute = async (
|
|
40
|
-
const q = (url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!q || (!did && q.length < 2) || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
|
|
44
|
-
|
|
45
|
-
if (did) {
|
|
46
|
-
if (perReader(did)) return json({ error: "too-many" }, 429);
|
|
47
|
-
} else {
|
|
48
|
-
let address = "unknown";
|
|
49
|
-
try {
|
|
50
|
-
address = clientAddress;
|
|
51
|
-
} catch {
|
|
52
|
-
// Some adapters can't tell; the overall cap still applies.
|
|
53
|
-
}
|
|
54
|
-
if (perAddress(address) || signedOutOverall("all")) return json({ error: "too-many" }, 429);
|
|
55
|
-
}
|
|
36
|
+
export const GET: APIRoute = async (context) => {
|
|
37
|
+
const q = (context.url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
|
|
38
|
+
if (!q || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
|
|
39
|
+
if (perAddress(addressOf(context)) || overall("all")) return json({ error: "too-many" }, 429);
|
|
56
40
|
|
|
57
41
|
try {
|
|
58
42
|
const actors = (await search(q)).map((actor) => {
|
package/virtual.d.ts
CHANGED
|
@@ -2,3 +2,8 @@ declare module "virtual:atmosphere-comments/settings" {
|
|
|
2
2
|
const settings: import("./lib/settings").AtmosphereSettings;
|
|
3
3
|
export default settings;
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
declare module "virtual:atmosphere-comments/public-settings" {
|
|
7
|
+
const settings: import("./lib/public-settings").PublicSettings;
|
|
8
|
+
export default settings;
|
|
9
|
+
}
|
package/bin/cli.mjs
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// npx astro-atmosphere-comments keygen
|
|
3
|
-
//
|
|
4
|
-
// Creates your site's private signing key and adds it to .env (in the folder
|
|
5
|
-
// you run it from) as ATMOSPHERE_PRIVATE_JWK, without printing it. Account
|
|
6
|
-
// hosts check your site's requests against the public half, which the
|
|
7
|
-
// integration publishes at /atmosphere/jwks.json. It refuses if .env already
|
|
8
|
-
// has a key: a new key signs every reader out.
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { JoseKey } from "@atproto/oauth-client-node";
|
|
11
|
-
|
|
12
|
-
const [command] = process.argv.slice(2);
|
|
13
|
-
if (command !== "keygen") {
|
|
14
|
-
console.log("Usage: npx astro-atmosphere-comments keygen\n\nAdds a new signing key to .env as ATMOSPHERE_PRIVATE_JWK.");
|
|
15
|
-
process.exit(command ? 1 : 0);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const envFile = ".env";
|
|
19
|
-
const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
20
|
-
if (/^ATMOSPHERE_PRIVATE_JWK=.+/m.test(existing)) {
|
|
21
|
-
console.error("ATMOSPHERE_PRIVATE_JWK is already set in .env; leaving it alone.");
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const key = await JoseKey.generate(["ES256"], "atmosphere-comments-1");
|
|
26
|
-
const line = `ATMOSPHERE_PRIVATE_JWK='${JSON.stringify(key.privateJwk)}'`;
|
|
27
|
-
const kept = existing.replace(/^ATMOSPHERE_PRIVATE_JWK=\s*$/m, "").replace(/\n*$/, existing ? "\n" : "");
|
|
28
|
-
writeFileSync(envFile, `${kept}${line}\n`, { mode: 0o600 });
|
|
29
|
-
console.log(
|
|
30
|
-
"Added ATMOSPHERE_PRIVATE_JWK to .env (not shown). Set the same value on your server, keep .env out of Git, and keep a backup somewhere safe.",
|
|
31
|
-
);
|
package/lib/oauth.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// The AT Protocol OAuth client: how this site asks a reader's account host for
|
|
2
|
-
// permission to post comments on their behalf.
|
|
3
|
-
//
|
|
4
|
-
// - Live, the site is a "confidential client": account hosts read its
|
|
5
|
-
// /oauth-client-metadata.json, and it proves who it is with a private key
|
|
6
|
-
// (ATMOSPHERE_PRIVATE_JWK, server-only). Sign-ins can then last for months.
|
|
7
|
-
// - In `astro dev`, it uses the protocol's local-testing client (no key, no
|
|
8
|
-
// public address needed). Open the site at http://127.0.0.1:<port>, not
|
|
9
|
-
// localhost, because the host sends readers back to that address.
|
|
10
|
-
import { requestLocalLock } from "@atproto/oauth-client";
|
|
11
|
-
import {
|
|
12
|
-
buildAtprotoLoopbackClientMetadata,
|
|
13
|
-
JoseKey,
|
|
14
|
-
NodeOAuthClient,
|
|
15
|
-
type NodeSavedSession,
|
|
16
|
-
type NodeSavedState,
|
|
17
|
-
} from "@atproto/oauth-client-node";
|
|
18
|
-
import { getSecret } from "astro:env/server";
|
|
19
|
-
import settings from "./settings";
|
|
20
|
-
import { fileStore } from "./store";
|
|
21
|
-
|
|
22
|
-
// Only what commenting needs: create posts, create (and delete again) likes
|
|
23
|
-
// and reposts, and read posts as the reader sees them (to know whether they've
|
|
24
|
-
// already liked or reposted), through Bluesky's AppView.
|
|
25
|
-
export const SCOPE = [
|
|
26
|
-
"atproto",
|
|
27
|
-
"repo:app.bsky.feed.post?action=create",
|
|
28
|
-
"repo:app.bsky.feed.like?action=create&action=delete",
|
|
29
|
-
"repo:app.bsky.feed.repost?action=create&action=delete",
|
|
30
|
-
"rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview",
|
|
31
|
-
].join(" ");
|
|
32
|
-
|
|
33
|
-
// Deleting posts is asked for separately, only when a reader first deletes a
|
|
34
|
-
// comment here, so most readers never grant it. (A permission can only name a
|
|
35
|
-
// kind of record, not particular posts: the delete route itself makes sure it
|
|
36
|
-
// is only used on the reader's own comments under the site's posts.)
|
|
37
|
-
export const DELETE_SCOPE = "repo:app.bsky.feed.post?action=delete";
|
|
38
|
-
|
|
39
|
-
// Following (and unfollowing) the site's own accounts is asked for the same
|
|
40
|
-
// way, the first time a reader presses Follow.
|
|
41
|
-
export const FOLLOW_SCOPE = "repo:app.bsky.graph.follow?action=create&action=delete";
|
|
42
|
-
|
|
43
|
-
// Everything the site may ask for, which its client metadata has to list.
|
|
44
|
-
const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE} ${FOLLOW_SCOPE}`;
|
|
45
|
-
|
|
46
|
-
const KEY_ID = "atmosphere-comments-1";
|
|
47
|
-
|
|
48
|
-
export const callbackPath = `${settings.base}/callback`;
|
|
49
|
-
|
|
50
|
-
export function clientMetadata() {
|
|
51
|
-
return {
|
|
52
|
-
// At the site's root, the address account hosts treat as the convention:
|
|
53
|
-
// Bluesky's approval page then shows just the site's address.
|
|
54
|
-
client_id: `${settings.site}/oauth-client-metadata.json`,
|
|
55
|
-
client_name: settings.clientName,
|
|
56
|
-
client_uri: settings.site,
|
|
57
|
-
...(settings.logoUri && { logo_uri: settings.logoUri }),
|
|
58
|
-
...(settings.policyUri && { policy_uri: settings.policyUri }),
|
|
59
|
-
...(settings.tosUri && { tos_uri: settings.tosUri }),
|
|
60
|
-
redirect_uris: [`${settings.site}${callbackPath}`] as [string],
|
|
61
|
-
scope: ALL_SCOPES,
|
|
62
|
-
grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
|
|
63
|
-
response_types: ["code"] as ["code"],
|
|
64
|
-
application_type: "web" as const,
|
|
65
|
-
token_endpoint_auth_method: "private_key_jwt" as const,
|
|
66
|
-
token_endpoint_auth_signing_alg: "ES256",
|
|
67
|
-
dpop_bound_access_tokens: true,
|
|
68
|
-
jwks_uri: `${settings.site}${settings.base}/jwks.json`,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let keyPromise: Promise<JoseKey> | undefined;
|
|
73
|
-
|
|
74
|
-
export function privateKey(): Promise<JoseKey> {
|
|
75
|
-
keyPromise ??= (async () => {
|
|
76
|
-
const jwk = getSecret("ATMOSPHERE_PRIVATE_JWK");
|
|
77
|
-
if (!jwk) throw new Error("ATMOSPHERE_PRIVATE_JWK is not set");
|
|
78
|
-
return JoseKey.fromImportable(jwk, KEY_ID);
|
|
79
|
-
})();
|
|
80
|
-
return keyPromise;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const stores = {
|
|
84
|
-
// A sign-in in progress: kept for an hour at most.
|
|
85
|
-
state: fileStore<NodeSavedState>("oauth-state", 60 * 60 * 1000),
|
|
86
|
-
// Each reader's tokens, by their DID.
|
|
87
|
-
session: fileStore<NodeSavedSession>("oauth-sessions"),
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
let clientPromise: Promise<NodeOAuthClient> | undefined;
|
|
91
|
-
|
|
92
|
-
/** The OAuth client. `origin` is only used in `astro dev`, for the local callback address. */
|
|
93
|
-
export function oauthClient(origin: string): Promise<NodeOAuthClient> {
|
|
94
|
-
clientPromise ??= (async () => {
|
|
95
|
-
const common = { stateStore: stores.state, sessionStore: stores.session, requestLock: requestLocalLock };
|
|
96
|
-
if (import.meta.env.DEV) {
|
|
97
|
-
const local = new URL(origin);
|
|
98
|
-
local.hostname = "127.0.0.1";
|
|
99
|
-
return new NodeOAuthClient({
|
|
100
|
-
...common,
|
|
101
|
-
clientMetadata: buildAtprotoLoopbackClientMetadata({
|
|
102
|
-
scope: ALL_SCOPES,
|
|
103
|
-
redirect_uris: [`${local.origin}${callbackPath}`],
|
|
104
|
-
}),
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return new NodeOAuthClient({ ...common, clientMetadata: clientMetadata(), keyset: [await privateKey()] });
|
|
108
|
-
})();
|
|
109
|
-
return clientPromise;
|
|
110
|
-
}
|
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
|
-
}
|
package/routes/callback.ts
DELETED
|
@@ -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
|
-
};
|