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.
- package/CHANGELOG.md +21 -0
- package/README.md +21 -23
- package/components/AtmosphereComments.astro +179 -78
- package/components/AtmosphereReactions.astro +86 -51
- package/components/SignInForm.astro +14 -3
- package/components/atmosphere.css +21 -0
- package/index.mjs +42 -32
- package/lib/client/account.ts +553 -0
- package/lib/client/composer.ts +7 -10
- package/lib/http.ts +28 -0
- package/lib/permissions.ts +108 -0
- package/lib/public-settings.ts +18 -0
- package/lib/thread.ts +36 -6
- package/lib/uris.ts +31 -0
- package/locales/de.json +20 -4
- package/locales/en.json +20 -4
- 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 +1 -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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// The reader's sign-in, kept only in their own browser.
|
|
2
|
+
//
|
|
3
|
+
// Readers sign in with AT Protocol OAuth as a browser ("public") client: the
|
|
4
|
+
// tokens, and the key they only work with (which the browser makes and won't
|
|
5
|
+
// let even this page's own code read out), stay in this browser's storage
|
|
6
|
+
// (IndexedDB). Comments, likes, reposts and follows go from the browser
|
|
7
|
+
// straight to the reader's own account host. The site's server never sees
|
|
8
|
+
// a reader's sign-in, so it has no way to act for them, and signing out
|
|
9
|
+
// cancels the sign-in at their host and deletes it here.
|
|
10
|
+
//
|
|
11
|
+
// What the routes used to check, this checks before anything is sent: only
|
|
12
|
+
// the site's announcements and the comments under them can be replied to,
|
|
13
|
+
// liked, reposted or quoted; only a reader's own replies under them can be
|
|
14
|
+
// deleted; only the site's accounts can be followed; and only the
|
|
15
|
+
// announcement's own author can hide comments under it.
|
|
16
|
+
//
|
|
17
|
+
// The OAuth library is loaded only when someone signs in, or comes back
|
|
18
|
+
// already signed in (remembered by their DID in localStorage), so other
|
|
19
|
+
// readers download none of it.
|
|
20
|
+
//
|
|
21
|
+
// send(path, body) answers like the old server routes did (a Response with
|
|
22
|
+
// JSON, the same error codes), so the components treat it the same way.
|
|
23
|
+
import type { BrowserOAuthClient, OAuthSession } from "@atproto/oauth-client-browser";
|
|
24
|
+
import { safeReturnPath, withStatus } from "../http";
|
|
25
|
+
import {
|
|
26
|
+
ALL_SCOPES,
|
|
27
|
+
callbackPath,
|
|
28
|
+
clientMetadata,
|
|
29
|
+
DEFAULT_PERMISSIONS,
|
|
30
|
+
grantedPermissions,
|
|
31
|
+
hasBase,
|
|
32
|
+
type Permission,
|
|
33
|
+
scopeFor,
|
|
34
|
+
scopesAllow,
|
|
35
|
+
} from "../permissions";
|
|
36
|
+
import settings from "../public-settings";
|
|
37
|
+
import { authorOf, inSiteThread, isPostUri, isSitePost, rkeyOf, type PostView } from "../uris";
|
|
38
|
+
|
|
39
|
+
type IdentityResolver = NonNullable<ConstructorParameters<typeof BrowserOAuthClient>[0]["identityResolver"]>;
|
|
40
|
+
|
|
41
|
+
const { base } = settings;
|
|
42
|
+
const REMEMBERED = "atmosphere:did";
|
|
43
|
+
const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
|
|
44
|
+
const MAX_GRAPHEMES = 300; // Bluesky's limit for a post
|
|
45
|
+
const graphemes = new Intl.Segmenter();
|
|
46
|
+
|
|
47
|
+
// localStorage can be missing or refuse (private windows, blocked storage):
|
|
48
|
+
// then a sign-in lasts only until the page is left.
|
|
49
|
+
const remembered = () => {
|
|
50
|
+
try {
|
|
51
|
+
return localStorage.getItem(REMEMBERED) ?? "";
|
|
52
|
+
} catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const remember = (did: string) => {
|
|
57
|
+
try {
|
|
58
|
+
if (did) localStorage.setItem(REMEMBERED, did);
|
|
59
|
+
else localStorage.removeItem(REMEMBERED);
|
|
60
|
+
} catch {
|
|
61
|
+
// See above.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Whether this browser may have a sign-in (so the library is worth loading). */
|
|
66
|
+
export const maybeSignedIn = () => !!remembered();
|
|
67
|
+
|
|
68
|
+
// Locally (`astro dev`) the protocol's development client is used: no
|
|
69
|
+
// metadata to publish, but the site must be opened at http://127.0.0.1.
|
|
70
|
+
const callbackUrl = () => `${import.meta.env.DEV ? location.origin : settings.site}${callbackPath(base)}`;
|
|
71
|
+
|
|
72
|
+
let clientPromise: Promise<BrowserOAuthClient> | undefined;
|
|
73
|
+
function client(): Promise<BrowserOAuthClient> {
|
|
74
|
+
clientPromise ??= (async () => {
|
|
75
|
+
const { atprotoLoopbackClientMetadata, buildAtprotoLoopbackClientId, BrowserOAuthClient } = await import(
|
|
76
|
+
"@atproto/oauth-client-browser"
|
|
77
|
+
);
|
|
78
|
+
const oauth = new BrowserOAuthClient({
|
|
79
|
+
clientMetadata: import.meta.env.DEV
|
|
80
|
+
? atprotoLoopbackClientMetadata(buildAtprotoLoopbackClientId({ scope: ALL_SCOPES, redirect_uris: [callbackUrl()] }))
|
|
81
|
+
: clientMetadata(settings),
|
|
82
|
+
// Accounts are looked up through the site (routes/identity.ts), so the
|
|
83
|
+
// browser contacts only the reader's own account host.
|
|
84
|
+
identityResolver: {
|
|
85
|
+
async resolve(id, options) {
|
|
86
|
+
const response = await fetch(`${base}/identity?id=${encodeURIComponent(id)}`, { signal: options?.signal });
|
|
87
|
+
if (!response.ok) throw new Error(`identity ${response.status}`);
|
|
88
|
+
return (await response.json()) as Awaited<ReturnType<IdentityResolver["resolve"]>>;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
// Signed out elsewhere (another tab), revoked, or expired.
|
|
92
|
+
onSessionDeleted: () => {
|
|
93
|
+
remember("");
|
|
94
|
+
sessionPromise = Promise.resolve(null);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return oauth;
|
|
98
|
+
})();
|
|
99
|
+
return clientPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let sessionPromise: Promise<OAuthSession | null> | undefined;
|
|
103
|
+
function session(): Promise<OAuthSession | null> {
|
|
104
|
+
sessionPromise ??= (async () => {
|
|
105
|
+
const did = remembered();
|
|
106
|
+
if (!did) return null;
|
|
107
|
+
try {
|
|
108
|
+
return await (await client()).restore(did);
|
|
109
|
+
} catch {
|
|
110
|
+
remember("");
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
return sessionPromise;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Reader = { did: string; session: OAuthSession; scopes: string[] };
|
|
118
|
+
|
|
119
|
+
/** A JSON answer, as the routes gave. */
|
|
120
|
+
const answer = (body: unknown, status = 200) =>
|
|
121
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
122
|
+
|
|
123
|
+
const signedOut = () => answer({ error: "signed-out" }, 401);
|
|
124
|
+
|
|
125
|
+
async function reader(): Promise<Reader | Response> {
|
|
126
|
+
const current = await session();
|
|
127
|
+
if (!current) return signedOut();
|
|
128
|
+
try {
|
|
129
|
+
const scopes = (await current.getTokenInfo(false)).scope.split(" ");
|
|
130
|
+
if (!hasBase(scopes)) return signedOut();
|
|
131
|
+
return { did: current.did, session: current, scopes };
|
|
132
|
+
} catch {
|
|
133
|
+
remember("");
|
|
134
|
+
return signedOut();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const allows = (reader: Reader, collection: string, action: string) => scopesAllow(reader.scopes, collection, action);
|
|
139
|
+
const needs = (permission: Permission) => answer({ error: "needs-permission", permission }, 403);
|
|
140
|
+
|
|
141
|
+
/** An XRPC call to the reader's own account host, as them. */
|
|
142
|
+
async function call<T>(reader: Reader, method: string, init?: { query?: string; body?: unknown; proxy?: boolean }): Promise<T> {
|
|
143
|
+
const response = await reader.session.fetchHandler(`/xrpc/${method}${init?.query ? `?${init.query}` : ""}`, {
|
|
144
|
+
method: init?.body === undefined ? "GET" : "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
...(init?.body !== undefined && { "content-type": "application/json" }),
|
|
147
|
+
...(init?.proxy && { "atproto-proxy": APPVIEW_PROXY }),
|
|
148
|
+
},
|
|
149
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
150
|
+
});
|
|
151
|
+
const data = (await response.json().catch(() => ({}))) as T & { error?: string };
|
|
152
|
+
if (!response.ok) throw Object.assign(new Error(`${method} ${response.status}`), { status: response.status, code: data.error });
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Posts as the reader sees them (including whether they've liked or reposted them). */
|
|
157
|
+
async function postsAsReader(reader: Reader, uris: string[]): Promise<PostView[]> {
|
|
158
|
+
const views: PostView[] = [];
|
|
159
|
+
// Bluesky answers for at most 25 posts at a time.
|
|
160
|
+
for (let start = 0; start < uris.length; start += 25) {
|
|
161
|
+
const query = uris
|
|
162
|
+
.slice(start, start + 25)
|
|
163
|
+
.map((uri) => `uris=${encodeURIComponent(uri)}`)
|
|
164
|
+
.join("&");
|
|
165
|
+
views.push(...((await call<{ posts?: PostView[] }>(reader, "app.bsky.feed.getPosts", { query, proxy: true })).posts ?? []));
|
|
166
|
+
}
|
|
167
|
+
return views;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const createRecord = (reader: Reader, collection: string, record: Record<string, unknown>) =>
|
|
171
|
+
call<{ uri: string; cid: string }>(reader, "com.atproto.repo.createRecord", { body: { repo: reader.did, collection, record } });
|
|
172
|
+
|
|
173
|
+
/** Deletes one of the reader's own records, given its AT URI. */
|
|
174
|
+
async function deleteRecord(reader: Reader, uri: string) {
|
|
175
|
+
const [, repo, collection, rkey] = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/) ?? [];
|
|
176
|
+
if (repo !== reader.did || !collection || !rkey) throw new Error("not the reader's record");
|
|
177
|
+
await call(reader, "com.atproto.repo.deleteRecord", { body: { repo: reader.did, collection, rkey } });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const post = (path: string, body: unknown) =>
|
|
181
|
+
fetch(`${base}/${path}`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
credentials: "same-origin",
|
|
184
|
+
headers: { "content-type": "application/json" },
|
|
185
|
+
body: JSON.stringify(body),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/** So a change shows when the page is next loaded (the site keeps threads for two minutes). */
|
|
189
|
+
const refreshThread = (root: string | undefined) => {
|
|
190
|
+
if (isSitePost(root)) void post("thread", { root }).catch(() => undefined);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Failed because the sign-in is gone (revoked, or expired): signed out. */
|
|
194
|
+
const failed = (error: unknown, what: string) => {
|
|
195
|
+
if ((error as { status?: number }).status === 401 || /^Token/.test((error as Error).name ?? "")) {
|
|
196
|
+
remember("");
|
|
197
|
+
sessionPromise = Promise.resolve(null);
|
|
198
|
+
return signedOut();
|
|
199
|
+
}
|
|
200
|
+
console.warn(`[atmosphere] ${what} failed:`, error instanceof Error ? error.message : error);
|
|
201
|
+
return answer({ error: "post-failed" }, 502);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
type Profile = { did: string; handle: string; displayName?: string; avatar?: string };
|
|
205
|
+
let profilePromise: Promise<Profile> | undefined;
|
|
206
|
+
function profile(did: string): Promise<Profile> {
|
|
207
|
+
profilePromise ??= fetch(`${base}/profile?did=${encodeURIComponent(did)}`)
|
|
208
|
+
.then((response) => (response.ok ? (response.json() as Promise<Profile>) : { did, handle: did }))
|
|
209
|
+
.catch(() => ({ did, handle: did }));
|
|
210
|
+
return profilePromise;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type Me = { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string; can?: Permission[] };
|
|
214
|
+
|
|
215
|
+
/** Who is signed in on this browser, and which permissions they've given (`can`). */
|
|
216
|
+
export async function me(): Promise<Me> {
|
|
217
|
+
if (!maybeSignedIn()) return { signedIn: false };
|
|
218
|
+
const signedIn = await reader();
|
|
219
|
+
if (signedIn instanceof Response) return { signedIn: false };
|
|
220
|
+
return { signedIn: true, ...(await profile(signedIn.did)), can: grantedPermissions(signedIn.scopes) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A comment: a reply to the announcement (`root`), or to a comment under it
|
|
224
|
+
// (`parent`); or a quote post of the announcement (`quote: true`).
|
|
225
|
+
async function comment(body: { text?: unknown; root?: unknown; parent?: unknown; quote?: unknown }) {
|
|
226
|
+
const signedIn = await reader();
|
|
227
|
+
if (signedIn instanceof Response) return signedIn;
|
|
228
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
229
|
+
const length = [...graphemes.segment(text)].length;
|
|
230
|
+
if (!length) return answer({ error: "empty" }, 400);
|
|
231
|
+
if (length > MAX_GRAPHEMES) return answer({ error: "too-long" }, 400);
|
|
232
|
+
const root = body.root;
|
|
233
|
+
if (!isSitePost(root)) return answer({ error: "not-allowed" }, 400);
|
|
234
|
+
const quote = body.quote === true;
|
|
235
|
+
const parent = !quote && body.parent !== undefined && body.parent !== root ? body.parent : undefined;
|
|
236
|
+
if (parent !== undefined && !isPostUri(parent)) return answer({ error: "not-allowed" }, 400);
|
|
237
|
+
// Readers who signed in only to like and repost haven't allowed posting yet.
|
|
238
|
+
if (!allows(signedIn, "app.bsky.feed.post", "create")) return needs("post");
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const posts = await postsAsReader(signedIn, parent ? [root, parent] : [root]);
|
|
242
|
+
const rootPost = posts.find((view) => view.uri === root);
|
|
243
|
+
const parentPost = parent ? posts.find((view) => view.uri === parent) : rootPost;
|
|
244
|
+
if (!rootPost || !parentPost) return answer({ error: "not-found" }, 404);
|
|
245
|
+
// A comment being replied to must belong to this announcement's thread.
|
|
246
|
+
if (parent && parentPost.record?.reply?.root?.uri !== root) return answer({ error: "not-allowed" }, 400);
|
|
247
|
+
|
|
248
|
+
// Links, @mentions and #tags become proper links, as in the Bluesky app.
|
|
249
|
+
let facets: unknown[] = [];
|
|
250
|
+
try {
|
|
251
|
+
const found = await post("richtext", { text });
|
|
252
|
+
if (found.ok) facets = ((await found.json()) as { facets?: unknown[] }).facets ?? [];
|
|
253
|
+
} catch {
|
|
254
|
+
// Posted as plain text.
|
|
255
|
+
}
|
|
256
|
+
const record = {
|
|
257
|
+
$type: "app.bsky.feed.post",
|
|
258
|
+
text,
|
|
259
|
+
...(facets.length && { facets }),
|
|
260
|
+
...(quote
|
|
261
|
+
? { embed: { $type: "app.bsky.embed.record", record: { uri: rootPost.uri, cid: rootPost.cid } } }
|
|
262
|
+
: {
|
|
263
|
+
reply: {
|
|
264
|
+
root: { uri: rootPost.uri, cid: rootPost.cid },
|
|
265
|
+
parent: { uri: parentPost.uri, cid: parentPost.cid },
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
createdAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
const created = await createRecord(signedIn, "app.bsky.feed.post", record);
|
|
271
|
+
refreshThread(root);
|
|
272
|
+
const author = await profile(signedIn.did);
|
|
273
|
+
return answer({
|
|
274
|
+
uri: created.uri,
|
|
275
|
+
url: `https://bsky.app/profile/${author.handle}/post/${created.uri.split("/").pop()}`,
|
|
276
|
+
text,
|
|
277
|
+
author,
|
|
278
|
+
createdAt: record.createdAt,
|
|
279
|
+
quote,
|
|
280
|
+
parent: parent ?? null,
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return failed(error, "post");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const COLLECTIONS = { like: "app.bsky.feed.like", repost: "app.bsky.feed.repost" } as const;
|
|
288
|
+
|
|
289
|
+
// Likes or reposts a post (an announcement, or a comment under one), or takes that back.
|
|
290
|
+
async function react(body: { post?: unknown; kind?: unknown; on?: unknown }) {
|
|
291
|
+
const signedIn = await reader();
|
|
292
|
+
if (signedIn instanceof Response) return signedIn;
|
|
293
|
+
const { post: uri, kind, on } = body;
|
|
294
|
+
if (!isPostUri(uri) || (kind !== "like" && kind !== "repost") || typeof on !== "boolean") return answer({ error: "bad-request" }, 400);
|
|
295
|
+
if (!allows(signedIn, COLLECTIONS[kind], on ? "create" : "delete")) return needs("react");
|
|
296
|
+
try {
|
|
297
|
+
const [view] = await postsAsReader(signedIn, [uri]);
|
|
298
|
+
if (!view) return answer({ error: "not-found" }, 404);
|
|
299
|
+
if (!inSiteThread(view)) return answer({ error: "not-allowed" }, 400);
|
|
300
|
+
const existing = kind === "like" ? view.viewer?.like : view.viewer?.repost;
|
|
301
|
+
if (on && !existing) {
|
|
302
|
+
await createRecord(signedIn, COLLECTIONS[kind], {
|
|
303
|
+
$type: COLLECTIONS[kind],
|
|
304
|
+
subject: { uri: view.uri, cid: view.cid },
|
|
305
|
+
createdAt: new Date().toISOString(),
|
|
306
|
+
});
|
|
307
|
+
} else if (!on && existing) {
|
|
308
|
+
await deleteRecord(signedIn, existing);
|
|
309
|
+
}
|
|
310
|
+
refreshThread(isSitePost(view.uri) ? view.uri : view.record?.reply?.root?.uri);
|
|
311
|
+
return answer({ kind, on });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return failed(error, kind);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Deletes one of the reader's own comments: a reply of theirs somewhere under
|
|
318
|
+
// one of the site's announcements. Nothing else, even though the permission
|
|
319
|
+
// covers all of their posts.
|
|
320
|
+
async function remove(body: { post?: unknown }) {
|
|
321
|
+
const signedIn = await reader();
|
|
322
|
+
if (signedIn instanceof Response) return signedIn;
|
|
323
|
+
const uri = body.post;
|
|
324
|
+
if (!isPostUri(uri) || !uri.startsWith(`at://${signedIn.did}/`)) return answer({ error: "not-allowed" }, 400);
|
|
325
|
+
if (!allows(signedIn, "app.bsky.feed.post", "delete")) return needs("delete");
|
|
326
|
+
try {
|
|
327
|
+
const [view] = await postsAsReader(signedIn, [uri]);
|
|
328
|
+
if (!view) return answer({ error: "not-found" }, 404);
|
|
329
|
+
const root = view.record?.reply?.root?.uri;
|
|
330
|
+
if (!isSitePost(root)) return answer({ error: "not-allowed" }, 400);
|
|
331
|
+
await deleteRecord(signedIn, view.uri);
|
|
332
|
+
refreshThread(root);
|
|
333
|
+
return answer({ deleted: view.uri });
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return failed(error, "delete");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Hides a comment under one of the reader's own announcements, here and on
|
|
340
|
+
// Bluesky (its threadgate's hiddenReplies, as the Bluesky app does). Only
|
|
341
|
+
// the announcement's author can; showing it again is done in the Bluesky app.
|
|
342
|
+
async function hide(body: { post?: unknown; root?: unknown }) {
|
|
343
|
+
const signedIn = await reader();
|
|
344
|
+
if (signedIn instanceof Response) return signedIn;
|
|
345
|
+
const { post: uri, root } = body;
|
|
346
|
+
if (!isPostUri(uri) || !isSitePost(root) || authorOf(root) !== signedIn.did || authorOf(uri) === signedIn.did) {
|
|
347
|
+
return answer({ error: "not-allowed" }, 400);
|
|
348
|
+
}
|
|
349
|
+
if (!allows(signedIn, "app.bsky.feed.threadgate", "create") || !allows(signedIn, "app.bsky.feed.threadgate", "update")) {
|
|
350
|
+
return needs("moderate");
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const [view] = await postsAsReader(signedIn, [uri]);
|
|
354
|
+
if (view?.record?.reply?.root?.uri !== root) return answer({ error: "not-allowed" }, 400);
|
|
355
|
+
const rkey = rkeyOf(root);
|
|
356
|
+
let existing: { cid?: string; value?: Record<string, unknown> } = {};
|
|
357
|
+
try {
|
|
358
|
+
existing = await call(signedIn, "com.atproto.repo.getRecord", {
|
|
359
|
+
query: new URLSearchParams({ repo: signedIn.did, collection: "app.bsky.feed.threadgate", rkey }).toString(),
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if ((error as { code?: string }).code !== "RecordNotFound") throw error;
|
|
363
|
+
}
|
|
364
|
+
const value = existing.value ?? {};
|
|
365
|
+
const hidden = new Set([...((value.hiddenReplies as string[] | undefined) ?? []), uri]);
|
|
366
|
+
await call(signedIn, "com.atproto.repo.putRecord", {
|
|
367
|
+
body: {
|
|
368
|
+
repo: signedIn.did,
|
|
369
|
+
collection: "app.bsky.feed.threadgate",
|
|
370
|
+
rkey,
|
|
371
|
+
// Who may reply (`allow`) stays as it was.
|
|
372
|
+
record: {
|
|
373
|
+
...value,
|
|
374
|
+
$type: "app.bsky.feed.threadgate",
|
|
375
|
+
post: root,
|
|
376
|
+
hiddenReplies: [...hidden],
|
|
377
|
+
createdAt: (value.createdAt as string | undefined) ?? new Date().toISOString(),
|
|
378
|
+
},
|
|
379
|
+
...(existing.cid && { swapRecord: existing.cid }),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
refreshThread(root);
|
|
383
|
+
return answer({ hidden: uri });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return failed(error, "hide");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Whether the reader follows `subject` (one of the site's accounts): the follow record's AT URI, or "". */
|
|
390
|
+
async function followRecord(did: string, subject: string): Promise<string> {
|
|
391
|
+
const response = await fetch(`${base}/following?${new URLSearchParams({ actor: did, subject })}`);
|
|
392
|
+
if (!response.ok) throw new Error(`following ${response.status}`);
|
|
393
|
+
return ((await response.json()) as { following?: string }).following ?? "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Whether the signed-in reader follows `subject`; null if that can't be told. */
|
|
397
|
+
export async function following(subject: string): Promise<boolean | null> {
|
|
398
|
+
const current = await session();
|
|
399
|
+
if (!current) return null;
|
|
400
|
+
try {
|
|
401
|
+
return !!(await followRecord(current.did, subject));
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Follows (or unfollows) one of the site's own accounts. Never yourself.
|
|
408
|
+
async function follow(body: { subject?: unknown; on?: unknown }) {
|
|
409
|
+
const signedIn = await reader();
|
|
410
|
+
if (signedIn instanceof Response) return signedIn;
|
|
411
|
+
const { subject, on } = body;
|
|
412
|
+
if (typeof subject !== "string" || !settings.allowedAuthors.includes(subject) || subject === signedIn.did || typeof on !== "boolean") {
|
|
413
|
+
return answer({ error: "not-allowed" }, 400);
|
|
414
|
+
}
|
|
415
|
+
if (!allows(signedIn, "app.bsky.graph.follow", on ? "create" : "delete")) return needs("follow");
|
|
416
|
+
try {
|
|
417
|
+
const existing = await followRecord(signedIn.did, subject);
|
|
418
|
+
if (on && !existing) {
|
|
419
|
+
await createRecord(signedIn, "app.bsky.graph.follow", { $type: "app.bsky.graph.follow", subject, createdAt: new Date().toISOString() });
|
|
420
|
+
} else if (!on && existing) {
|
|
421
|
+
await deleteRecord(signedIn, existing);
|
|
422
|
+
}
|
|
423
|
+
return answer({ following: on });
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return failed(error, "follow");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Whether the reader has liked or reposted these posts (those in the site's threads). */
|
|
430
|
+
export async function state(uris: string[]) {
|
|
431
|
+
const signedIn = await reader();
|
|
432
|
+
if (signedIn instanceof Response) return signedIn;
|
|
433
|
+
try {
|
|
434
|
+
const views = await postsAsReader(signedIn, [...new Set(uris)].filter(isPostUri).slice(0, 100));
|
|
435
|
+
const posts: Record<string, { liked: boolean; reposted: boolean }> = {};
|
|
436
|
+
for (const view of views) {
|
|
437
|
+
if (inSiteThread(view)) posts[view.uri] = { liked: !!view.viewer?.like, reposted: !!view.viewer?.repost };
|
|
438
|
+
}
|
|
439
|
+
return answer({ posts });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return failed(error, "state");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Signs out: cancels the sign-in at the reader's account host, and deletes it from this browser. */
|
|
446
|
+
export async function signOut() {
|
|
447
|
+
const current = await session().catch(() => null);
|
|
448
|
+
remember("");
|
|
449
|
+
sessionPromise = Promise.resolve(null);
|
|
450
|
+
profilePromise = undefined;
|
|
451
|
+
try {
|
|
452
|
+
await current?.signOut();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.warn("[atmosphere] sign-out:", error instanceof Error ? error.message : error);
|
|
455
|
+
}
|
|
456
|
+
return answer({ signedIn: false });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** What the comments and reactions send, answered as the old routes did. */
|
|
460
|
+
export function send(path: string, body: unknown): Promise<Response> {
|
|
461
|
+
const actions: Record<string, (body: never) => Promise<Response>> = { comment, react, delete: remove, hide, follow };
|
|
462
|
+
const action = actions[path];
|
|
463
|
+
return action ? action(body as never) : Promise.resolve(answer({ error: "bad-request" }, 400));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const HANDLE = /^(did:[a-z0-9:._%-]+|[a-z0-9.-]+\.[a-z0-9-]+)$/;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Sends the reader to their account host to approve everything they can do
|
|
470
|
+
* here (DEFAULT_PERMISSIONS, plus "moderate" for the site's own accounts)
|
|
471
|
+
* and any `permissions` named, coming back to `returnTo`. A new approval
|
|
472
|
+
* replaces the old one, so whatever the same account already allowed is
|
|
473
|
+
* asked for again too. If the account can't be found, the reader is sent
|
|
474
|
+
* to `returnTo` with ?atmosphere=<problem>, as the comments expect.
|
|
475
|
+
*/
|
|
476
|
+
export async function signIn(input: string, options: { permissions?: Permission[]; returnTo?: string; lang?: string } = {}) {
|
|
477
|
+
const returnTo = safeReturnPath(options.returnTo ?? `${location.pathname}${location.search}${location.hash}`);
|
|
478
|
+
const handle = input.trim().replace(/^@/, "").toLowerCase();
|
|
479
|
+
if (!handle || handle.length > 253 || !HANDLE.test(handle)) return location.assign(withStatus(returnTo, "invalid-handle"));
|
|
480
|
+
const permissions = new Set<Permission>([...DEFAULT_PERMISSIONS, ...(options.permissions ?? [])]);
|
|
481
|
+
// The site's own accounts can also hide comments under their announcements.
|
|
482
|
+
try {
|
|
483
|
+
const did = handle.startsWith("did:")
|
|
484
|
+
? handle
|
|
485
|
+
: (((await (await fetch(`${base}/identity?id=${encodeURIComponent(handle)}`)).json()) as { did?: string }).did ?? "");
|
|
486
|
+
if (settings.allowedAuthors.includes(did)) permissions.add("moderate");
|
|
487
|
+
} catch {
|
|
488
|
+
// Found again below, or reported as not found.
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const current = await session();
|
|
492
|
+
if (current && current.did === handle) {
|
|
493
|
+
for (const name of grantedPermissions((await current.getTokenInfo(false)).scope.split(" "))) permissions.add(name);
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
// Not signed in any more: just what's asked for.
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const url = await (await client()).authorize(handle, {
|
|
500
|
+
scope: scopeFor(permissions),
|
|
501
|
+
state: JSON.stringify({ returnTo }),
|
|
502
|
+
...(options.lang && { ui_locales: options.lang }),
|
|
503
|
+
});
|
|
504
|
+
location.assign(url.href);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
console.warn("[atmosphere] sign-in could not start:", error instanceof Error ? error.message : error);
|
|
507
|
+
location.assign(withStatus(returnTo, "account-not-found"));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Signs in with a sign-in form (SignInForm.astro): its handle box, return address and any permission fields. */
|
|
512
|
+
export function handleSignInForm(form: HTMLFormElement, lang?: string) {
|
|
513
|
+
form.addEventListener("submit", (event) => {
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
const data = new FormData(form);
|
|
516
|
+
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
517
|
+
if (button) button.disabled = true;
|
|
518
|
+
void signIn(String(data.get("handle") ?? ""), {
|
|
519
|
+
permissions: data.getAll("permission").map(String) as Permission[],
|
|
520
|
+
returnTo: String(data.get("returnTo") ?? ""),
|
|
521
|
+
lang,
|
|
522
|
+
}).finally(() => {
|
|
523
|
+
// Back from the account host with the browser's back button.
|
|
524
|
+
window.setTimeout(() => button && (button.disabled = false), 1000);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
// Pages restored from the back-forward cache keep a disabled button otherwise.
|
|
528
|
+
window.addEventListener("pageshow", () => form.querySelector<HTMLButtonElement>('button[type="submit"]')?.removeAttribute("disabled"));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** On the callback page: finishes signing in, then back to the page the reader was on. */
|
|
532
|
+
export async function finishSignIn() {
|
|
533
|
+
let returnTo = "/";
|
|
534
|
+
const back = (value: unknown) => {
|
|
535
|
+
try {
|
|
536
|
+
returnTo = safeReturnPath((JSON.parse(String(value)) as { returnTo?: unknown }).returnTo);
|
|
537
|
+
} catch {
|
|
538
|
+
// Back to the home page.
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
try {
|
|
542
|
+
const { session: signedIn, state: value } = await (await client()).initCallback(undefined, callbackUrl() as `https://${string}`);
|
|
543
|
+
remember(signedIn.did);
|
|
544
|
+
back(value);
|
|
545
|
+
location.replace(withStatus(returnTo, "signed-in"));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
// The reader declined, took too long, or something went wrong. The
|
|
548
|
+
// library's error still carries our state, so they can go back.
|
|
549
|
+
back((error as { state?: unknown }).state);
|
|
550
|
+
console.warn("[atmosphere] sign-in failed:", error instanceof Error ? error.message : error);
|
|
551
|
+
location.replace(withStatus(returnTo, "sign-in-failed"));
|
|
552
|
+
}
|
|
553
|
+
}
|
package/lib/client/composer.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// posting (as a reply, or a quote post), and the quote preview. Used by
|
|
3
3
|
// AtmosphereComments (its main box and every reply box) and by the reply and
|
|
4
4
|
// quote overlay of AtmosphereReactions. The markup is Composer.astro.
|
|
5
|
+
import { send } from "./account";
|
|
5
6
|
|
|
6
7
|
export const MAX = 300; // Bluesky's limit for a post, in characters as people count them
|
|
7
8
|
const graphemes = new Intl.Segmenter();
|
|
@@ -9,7 +10,7 @@ export const count = (text: string) => [...graphemes.segment(text.trim())].lengt
|
|
|
9
10
|
|
|
10
11
|
export type Suggestion = { handle: string; displayName?: string; avatar?: string };
|
|
11
12
|
export type Person = { did: string; handle: string; displayName?: string; avatar?: string };
|
|
12
|
-
/** What
|
|
13
|
+
/** What posting a comment (lib/client/account.ts) answers with. */
|
|
13
14
|
export type Posted = {
|
|
14
15
|
uri: string;
|
|
15
16
|
url: string;
|
|
@@ -31,11 +32,12 @@ export type ComposerContext = {
|
|
|
31
32
|
export type ComposerBehaviour = {
|
|
32
33
|
/** The post being replied to or quoted (the announcement, or another post by the site's accounts). */
|
|
33
34
|
root: () => string;
|
|
34
|
-
/** More for
|
|
35
|
+
/** More for posting: { parent } for a reply to a comment, { quote: true } for a quote post. */
|
|
35
36
|
extra: () => Record<string, unknown>;
|
|
36
37
|
tell: (message: string) => void;
|
|
37
38
|
onPosted: (data: Posted) => void;
|
|
38
|
-
|
|
39
|
+
/** `permission`, with "needs-permission": the one the reader hasn't given yet. */
|
|
40
|
+
onError: (code: string, tell: (message: string) => void, permission?: string) => void;
|
|
39
41
|
onInput?: () => void;
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -158,14 +160,9 @@ export function setUpComposer(form: HTMLFormElement, ctx: ComposerContext, behav
|
|
|
158
160
|
submit.disabled = true;
|
|
159
161
|
behaviour.tell(ctx.t("composer.posting"));
|
|
160
162
|
try {
|
|
161
|
-
const response = await
|
|
162
|
-
method: "POST",
|
|
163
|
-
credentials: "same-origin",
|
|
164
|
-
headers: { "content-type": "application/json" },
|
|
165
|
-
body: JSON.stringify({ text, root: behaviour.root(), ...behaviour.extra() }),
|
|
166
|
-
});
|
|
163
|
+
const response = await send("comment", { text, root: behaviour.root(), ...behaviour.extra() });
|
|
167
164
|
const data = await response.json();
|
|
168
|
-
if (!response.ok) return behaviour.onError(data.error, behaviour.tell);
|
|
165
|
+
if (!response.ok) return behaviour.onError(data.error, behaviour.tell, data.permission);
|
|
169
166
|
textarea.value = "";
|
|
170
167
|
updateCount();
|
|
171
168
|
behaviour.onPosted(data);
|
package/lib/http.ts
CHANGED
|
@@ -24,3 +24,31 @@ export const json = (body: unknown, status = 200) =>
|
|
|
24
24
|
export function fromThisSite(request: Request, url: URL): boolean {
|
|
25
25
|
return request.headers.get("origin") === url.origin;
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** A simple limit per key: at most `perMinute` a minute and `perHour` an hour. True when over it. */
|
|
29
|
+
export function rateLimit(perMinute: number, perHour: number) {
|
|
30
|
+
const recent = new Map<string, number[]>();
|
|
31
|
+
return (key: string): boolean => {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const times = (recent.get(key) ?? []).filter((time) => now - time < 60 * 60 * 1000);
|
|
34
|
+
if (times.length) recent.set(key, times);
|
|
35
|
+
else recent.delete(key);
|
|
36
|
+
if (times.length >= perHour || times.filter((time) => now - time < 60 * 1000).length >= perMinute) return true;
|
|
37
|
+
times.push(now);
|
|
38
|
+
recent.set(key, times);
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The visitor's address, for rate limits only (never stored or logged).
|
|
45
|
+
* Behind a proxy every visitor may share one, so routes add an overall cap.
|
|
46
|
+
*/
|
|
47
|
+
export function addressOf(context: { clientAddress: string }): string {
|
|
48
|
+
try {
|
|
49
|
+
return context.clientAddress;
|
|
50
|
+
} catch {
|
|
51
|
+
// Some adapters can't tell; the overall cap still applies.
|
|
52
|
+
return "unknown";
|
|
53
|
+
}
|
|
54
|
+
}
|