@rmdes/indiekit-endpoint-activitypub 2.15.3 → 3.0.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.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Account endpoints for Mastodon Client API.
3
+ *
4
+ * Phase 1: verify_credentials, preferences, account lookup
5
+ * Phase 2: relationships, follow/unfollow, account statuses
6
+ */
7
+ import express from "express";
8
+ import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
9
+ import { accountId, remoteActorId } from "../helpers/id-mapping.js";
10
+
11
+ const router = express.Router(); // eslint-disable-line new-cap
12
+
13
+ // ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
14
+
15
+ router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
16
+ try {
17
+ const token = req.mastodonToken;
18
+ if (!token) {
19
+ return res.status(401).json({ error: "The access token is invalid" });
20
+ }
21
+
22
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
23
+ const collections = req.app.locals.mastodonCollections;
24
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
25
+ const handle = pluginOptions.handle || "user";
26
+
27
+ const profile = await collections.ap_profile.findOne({});
28
+ if (!profile) {
29
+ return res.status(404).json({ error: "Profile not found" });
30
+ }
31
+
32
+ // Get counts
33
+ let counts = {};
34
+ try {
35
+ const [statuses, followers, following] = await Promise.all([
36
+ collections.ap_timeline.countDocuments({
37
+ "author.url": profile.url,
38
+ }),
39
+ collections.ap_followers.countDocuments({}),
40
+ collections.ap_following.countDocuments({}),
41
+ ]);
42
+ counts = { statuses, followers, following };
43
+ } catch {
44
+ counts = { statuses: 0, followers: 0, following: 0 };
45
+ }
46
+
47
+ const account = serializeCredentialAccount(profile, {
48
+ baseUrl,
49
+ handle,
50
+ counts,
51
+ });
52
+
53
+ res.json(account);
54
+ } catch (error) {
55
+ next(error);
56
+ }
57
+ });
58
+
59
+ // ─── GET /api/v1/preferences ─────────────────────────────────────────────────
60
+
61
+ router.get("/api/v1/preferences", (req, res) => {
62
+ res.json({
63
+ "posting:default:visibility": "public",
64
+ "posting:default:sensitive": false,
65
+ "posting:default:language": "en",
66
+ "reading:expand:media": "default",
67
+ "reading:expand:spoilers": false,
68
+ });
69
+ });
70
+
71
+ // ─── GET /api/v1/accounts/lookup ─────────────────────────────────────────────
72
+
73
+ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
74
+ try {
75
+ const { acct } = req.query;
76
+ if (!acct) {
77
+ return res.status(400).json({ error: "Missing acct parameter" });
78
+ }
79
+
80
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
81
+ const collections = req.app.locals.mastodonCollections;
82
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
83
+ const handle = pluginOptions.handle || "user";
84
+
85
+ // Check if looking up local account
86
+ const bareAcct = acct.startsWith("@") ? acct.slice(1) : acct;
87
+ const localDomain = req.get("host");
88
+
89
+ if (
90
+ bareAcct === handle ||
91
+ bareAcct === `${handle}@${localDomain}`
92
+ ) {
93
+ const profile = await collections.ap_profile.findOne({});
94
+ if (profile) {
95
+ return res.json(
96
+ serializeAccount(profile, { baseUrl, isLocal: true, handle }),
97
+ );
98
+ }
99
+ }
100
+
101
+ // Check followers/following for known remote actors
102
+ const follower = await collections.ap_followers.findOne({
103
+ $or: [
104
+ { handle: `@${bareAcct}` },
105
+ { handle: bareAcct },
106
+ ],
107
+ });
108
+ if (follower) {
109
+ return res.json(
110
+ serializeAccount(
111
+ { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle },
112
+ { baseUrl },
113
+ ),
114
+ );
115
+ }
116
+
117
+ return res.status(404).json({ error: "Record not found" });
118
+ } catch (error) {
119
+ next(error);
120
+ }
121
+ });
122
+
123
+ // ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
124
+
125
+ router.get("/api/v1/accounts/:id", async (req, res, next) => {
126
+ try {
127
+ const { id } = req.params;
128
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
129
+ const collections = req.app.locals.mastodonCollections;
130
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
131
+ const handle = pluginOptions.handle || "user";
132
+
133
+ // Check if it's the local profile
134
+ const profile = await collections.ap_profile.findOne({});
135
+ if (profile && profile._id.toString() === id) {
136
+ return res.json(
137
+ serializeAccount(profile, { baseUrl, isLocal: true, handle }),
138
+ );
139
+ }
140
+
141
+ // Search known actors (followers, following, timeline authors)
142
+ // by checking if the deterministic hash matches
143
+ const follower = await collections.ap_followers
144
+ .find({})
145
+ .toArray();
146
+ for (const f of follower) {
147
+ if (remoteActorId(f.actorUrl) === id) {
148
+ return res.json(
149
+ serializeAccount(
150
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
151
+ { baseUrl },
152
+ ),
153
+ );
154
+ }
155
+ }
156
+
157
+ const following = await collections.ap_following
158
+ .find({})
159
+ .toArray();
160
+ for (const f of following) {
161
+ if (remoteActorId(f.actorUrl) === id) {
162
+ return res.json(
163
+ serializeAccount(
164
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
165
+ { baseUrl },
166
+ ),
167
+ );
168
+ }
169
+ }
170
+
171
+ // Try timeline authors
172
+ const timelineItem = await collections.ap_timeline.findOne({
173
+ $expr: { $ne: [{ $type: "$author.url" }, "missing"] },
174
+ });
175
+ // For now, if not found in known actors, return 404
176
+ return res.status(404).json({ error: "Record not found" });
177
+ } catch (error) {
178
+ next(error);
179
+ }
180
+ });
181
+
182
+ // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
183
+
184
+ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
185
+ try {
186
+ // id[] can come as single value or array
187
+ let ids = req.query["id[]"] || req.query.id || [];
188
+ if (!Array.isArray(ids)) ids = [ids];
189
+
190
+ if (ids.length === 0) {
191
+ return res.json([]);
192
+ }
193
+
194
+ const collections = req.app.locals.mastodonCollections;
195
+
196
+ // Load all followers/following for efficient lookup
197
+ const [followers, following, blocked, muted] = await Promise.all([
198
+ collections.ap_followers.find({}).toArray(),
199
+ collections.ap_following.find({}).toArray(),
200
+ collections.ap_blocked.find({}).toArray(),
201
+ collections.ap_muted.find({}).toArray(),
202
+ ]);
203
+
204
+ const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
205
+ const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
206
+ const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
207
+ const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
208
+
209
+ const relationships = ids.map((id) => ({
210
+ id,
211
+ following: followingIds.has(id),
212
+ showing_reblogs: followingIds.has(id),
213
+ notifying: false,
214
+ languages: [],
215
+ followed_by: followerIds.has(id),
216
+ blocking: blockedIds.has(id),
217
+ blocked_by: false,
218
+ muting: mutedIds.has(id),
219
+ muting_notifications: mutedIds.has(id),
220
+ requested: false,
221
+ requested_by: false,
222
+ domain_blocking: false,
223
+ endorsed: false,
224
+ note: "",
225
+ }));
226
+
227
+ res.json(relationships);
228
+ } catch (error) {
229
+ next(error);
230
+ }
231
+ });
232
+
233
+ // ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
234
+
235
+ router.get("/api/v1/accounts/familiar_followers", (req, res) => {
236
+ // Stub — returns empty for each requested ID
237
+ let ids = req.query["id[]"] || req.query.id || [];
238
+ if (!Array.isArray(ids)) ids = [ids];
239
+ res.json(ids.map((id) => ({ id, accounts: [] })));
240
+ });
241
+
242
+ // ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
243
+
244
+ router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
245
+ try {
246
+ const token = req.mastodonToken;
247
+ if (!token) {
248
+ return res.status(401).json({ error: "The access token is invalid" });
249
+ }
250
+
251
+ const { id } = req.params;
252
+ const collections = req.app.locals.mastodonCollections;
253
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
254
+
255
+ // Resolve the account ID to an actor URL
256
+ const actorUrl = await resolveActorUrl(id, collections);
257
+ if (!actorUrl) {
258
+ return res.status(404).json({ error: "Record not found" });
259
+ }
260
+
261
+ // Use the plugin's followActor method
262
+ if (pluginOptions.followActor) {
263
+ const result = await pluginOptions.followActor(actorUrl);
264
+ if (!result.ok) {
265
+ return res.status(422).json({ error: result.error || "Follow failed" });
266
+ }
267
+ }
268
+
269
+ // Return relationship
270
+ const followingIds = new Set();
271
+ const following = await collections.ap_following.find({}).toArray();
272
+ for (const f of following) {
273
+ followingIds.add(remoteActorId(f.actorUrl));
274
+ }
275
+
276
+ const followerIds = new Set();
277
+ const followers = await collections.ap_followers.find({}).toArray();
278
+ for (const f of followers) {
279
+ followerIds.add(remoteActorId(f.actorUrl));
280
+ }
281
+
282
+ res.json({
283
+ id,
284
+ following: true,
285
+ showing_reblogs: true,
286
+ notifying: false,
287
+ languages: [],
288
+ followed_by: followerIds.has(id),
289
+ blocking: false,
290
+ blocked_by: false,
291
+ muting: false,
292
+ muting_notifications: false,
293
+ requested: false,
294
+ requested_by: false,
295
+ domain_blocking: false,
296
+ endorsed: false,
297
+ note: "",
298
+ });
299
+ } catch (error) {
300
+ next(error);
301
+ }
302
+ });
303
+
304
+ // ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
305
+
306
+ router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
307
+ try {
308
+ const token = req.mastodonToken;
309
+ if (!token) {
310
+ return res.status(401).json({ error: "The access token is invalid" });
311
+ }
312
+
313
+ const { id } = req.params;
314
+ const collections = req.app.locals.mastodonCollections;
315
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
316
+
317
+ const actorUrl = await resolveActorUrl(id, collections);
318
+ if (!actorUrl) {
319
+ return res.status(404).json({ error: "Record not found" });
320
+ }
321
+
322
+ if (pluginOptions.unfollowActor) {
323
+ const result = await pluginOptions.unfollowActor(actorUrl);
324
+ if (!result.ok) {
325
+ return res.status(422).json({ error: result.error || "Unfollow failed" });
326
+ }
327
+ }
328
+
329
+ const followerIds = new Set();
330
+ const followers = await collections.ap_followers.find({}).toArray();
331
+ for (const f of followers) {
332
+ followerIds.add(remoteActorId(f.actorUrl));
333
+ }
334
+
335
+ res.json({
336
+ id,
337
+ following: false,
338
+ showing_reblogs: true,
339
+ notifying: false,
340
+ languages: [],
341
+ followed_by: followerIds.has(id),
342
+ blocking: false,
343
+ blocked_by: false,
344
+ muting: false,
345
+ muting_notifications: false,
346
+ requested: false,
347
+ requested_by: false,
348
+ domain_blocking: false,
349
+ endorsed: false,
350
+ note: "",
351
+ });
352
+ } catch (error) {
353
+ next(error);
354
+ }
355
+ });
356
+
357
+ // ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
358
+
359
+ router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
360
+ try {
361
+ const token = req.mastodonToken;
362
+ if (!token) {
363
+ return res.status(401).json({ error: "The access token is invalid" });
364
+ }
365
+
366
+ const { id } = req.params;
367
+ const collections = req.app.locals.mastodonCollections;
368
+
369
+ const actorUrl = await resolveActorUrl(id, collections);
370
+ if (actorUrl && collections.ap_muted) {
371
+ await collections.ap_muted.updateOne(
372
+ { url: actorUrl },
373
+ { $set: { url: actorUrl, createdAt: new Date().toISOString() } },
374
+ { upsert: true },
375
+ );
376
+ }
377
+
378
+ res.json({
379
+ id,
380
+ following: false,
381
+ showing_reblogs: true,
382
+ notifying: false,
383
+ languages: [],
384
+ followed_by: false,
385
+ blocking: false,
386
+ blocked_by: false,
387
+ muting: true,
388
+ muting_notifications: true,
389
+ requested: false,
390
+ requested_by: false,
391
+ domain_blocking: false,
392
+ endorsed: false,
393
+ note: "",
394
+ });
395
+ } catch (error) {
396
+ next(error);
397
+ }
398
+ });
399
+
400
+ // ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
401
+
402
+ router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
403
+ try {
404
+ const token = req.mastodonToken;
405
+ if (!token) {
406
+ return res.status(401).json({ error: "The access token is invalid" });
407
+ }
408
+
409
+ const { id } = req.params;
410
+ const collections = req.app.locals.mastodonCollections;
411
+
412
+ const actorUrl = await resolveActorUrl(id, collections);
413
+ if (actorUrl && collections.ap_muted) {
414
+ await collections.ap_muted.deleteOne({ url: actorUrl });
415
+ }
416
+
417
+ res.json({
418
+ id,
419
+ following: false,
420
+ showing_reblogs: true,
421
+ notifying: false,
422
+ languages: [],
423
+ followed_by: false,
424
+ blocking: false,
425
+ blocked_by: false,
426
+ muting: false,
427
+ muting_notifications: false,
428
+ requested: false,
429
+ requested_by: false,
430
+ domain_blocking: false,
431
+ endorsed: false,
432
+ note: "",
433
+ });
434
+ } catch (error) {
435
+ next(error);
436
+ }
437
+ });
438
+
439
+ // ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
440
+
441
+ router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
442
+ try {
443
+ const token = req.mastodonToken;
444
+ if (!token) {
445
+ return res.status(401).json({ error: "The access token is invalid" });
446
+ }
447
+
448
+ const { id } = req.params;
449
+ const collections = req.app.locals.mastodonCollections;
450
+
451
+ const actorUrl = await resolveActorUrl(id, collections);
452
+ if (actorUrl && collections.ap_blocked) {
453
+ await collections.ap_blocked.updateOne(
454
+ { url: actorUrl },
455
+ { $set: { url: actorUrl, createdAt: new Date().toISOString() } },
456
+ { upsert: true },
457
+ );
458
+ }
459
+
460
+ res.json({
461
+ id,
462
+ following: false,
463
+ showing_reblogs: true,
464
+ notifying: false,
465
+ languages: [],
466
+ followed_by: false,
467
+ blocking: true,
468
+ blocked_by: false,
469
+ muting: false,
470
+ muting_notifications: false,
471
+ requested: false,
472
+ requested_by: false,
473
+ domain_blocking: false,
474
+ endorsed: false,
475
+ note: "",
476
+ });
477
+ } catch (error) {
478
+ next(error);
479
+ }
480
+ });
481
+
482
+ // ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
483
+
484
+ router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
485
+ try {
486
+ const token = req.mastodonToken;
487
+ if (!token) {
488
+ return res.status(401).json({ error: "The access token is invalid" });
489
+ }
490
+
491
+ const { id } = req.params;
492
+ const collections = req.app.locals.mastodonCollections;
493
+
494
+ const actorUrl = await resolveActorUrl(id, collections);
495
+ if (actorUrl && collections.ap_blocked) {
496
+ await collections.ap_blocked.deleteOne({ url: actorUrl });
497
+ }
498
+
499
+ res.json({
500
+ id,
501
+ following: false,
502
+ showing_reblogs: true,
503
+ notifying: false,
504
+ languages: [],
505
+ followed_by: false,
506
+ blocking: false,
507
+ blocked_by: false,
508
+ muting: false,
509
+ muting_notifications: false,
510
+ requested: false,
511
+ requested_by: false,
512
+ domain_blocking: false,
513
+ endorsed: false,
514
+ note: "",
515
+ });
516
+ } catch (error) {
517
+ next(error);
518
+ }
519
+ });
520
+
521
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
522
+
523
+ /**
524
+ * Resolve an account ID back to an actor URL by scanning followers/following.
525
+ */
526
+ async function resolveActorUrl(id, collections) {
527
+ // Check if it's the local profile
528
+ const profile = await collections.ap_profile.findOne({});
529
+ if (profile && profile._id.toString() === id) {
530
+ return profile.url;
531
+ }
532
+
533
+ // Check followers
534
+ const followers = await collections.ap_followers.find({}).toArray();
535
+ for (const f of followers) {
536
+ if (remoteActorId(f.actorUrl) === id) {
537
+ return f.actorUrl;
538
+ }
539
+ }
540
+
541
+ // Check following
542
+ const following = await collections.ap_following.find({}).toArray();
543
+ for (const f of following) {
544
+ if (remoteActorId(f.actorUrl) === id) {
545
+ return f.actorUrl;
546
+ }
547
+ }
548
+
549
+ return null;
550
+ }
551
+
552
+ export default router;