@rmdes/indiekit-endpoint-activitypub 2.15.4 → 3.2.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,740 @@
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 { serializeStatus } from "../entities/status.js";
10
+ import { accountId, remoteActorId } from "../helpers/id-mapping.js";
11
+ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
12
+
13
+ const router = express.Router(); // eslint-disable-line new-cap
14
+
15
+ // ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
16
+
17
+ router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
18
+ try {
19
+ const token = req.mastodonToken;
20
+ if (!token) {
21
+ return res.status(401).json({ error: "The access token is invalid" });
22
+ }
23
+
24
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
25
+ const collections = req.app.locals.mastodonCollections;
26
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
27
+ const handle = pluginOptions.handle || "user";
28
+
29
+ const profile = await collections.ap_profile.findOne({});
30
+ if (!profile) {
31
+ return res.status(404).json({ error: "Profile not found" });
32
+ }
33
+
34
+ // Get counts
35
+ let counts = {};
36
+ try {
37
+ const [statuses, followers, following] = await Promise.all([
38
+ collections.ap_timeline.countDocuments({
39
+ "author.url": profile.url,
40
+ }),
41
+ collections.ap_followers.countDocuments({}),
42
+ collections.ap_following.countDocuments({}),
43
+ ]);
44
+ counts = { statuses, followers, following };
45
+ } catch {
46
+ counts = { statuses: 0, followers: 0, following: 0 };
47
+ }
48
+
49
+ const account = serializeCredentialAccount(profile, {
50
+ baseUrl,
51
+ handle,
52
+ counts,
53
+ });
54
+
55
+ res.json(account);
56
+ } catch (error) {
57
+ next(error);
58
+ }
59
+ });
60
+
61
+ // ─── GET /api/v1/preferences ─────────────────────────────────────────────────
62
+
63
+ router.get("/api/v1/preferences", (req, res) => {
64
+ res.json({
65
+ "posting:default:visibility": "public",
66
+ "posting:default:sensitive": false,
67
+ "posting:default:language": "en",
68
+ "reading:expand:media": "default",
69
+ "reading:expand:spoilers": false,
70
+ });
71
+ });
72
+
73
+ // ─── GET /api/v1/accounts/lookup ─────────────────────────────────────────────
74
+
75
+ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
76
+ try {
77
+ const { acct } = req.query;
78
+ if (!acct) {
79
+ return res.status(400).json({ error: "Missing acct parameter" });
80
+ }
81
+
82
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
83
+ const collections = req.app.locals.mastodonCollections;
84
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
85
+ const handle = pluginOptions.handle || "user";
86
+
87
+ // Check if looking up local account
88
+ const bareAcct = acct.startsWith("@") ? acct.slice(1) : acct;
89
+ const localDomain = req.get("host");
90
+
91
+ if (
92
+ bareAcct === handle ||
93
+ bareAcct === `${handle}@${localDomain}`
94
+ ) {
95
+ const profile = await collections.ap_profile.findOne({});
96
+ if (profile) {
97
+ return res.json(
98
+ serializeAccount(profile, { baseUrl, isLocal: true, handle }),
99
+ );
100
+ }
101
+ }
102
+
103
+ // Check followers/following for known remote actors
104
+ const follower = await collections.ap_followers.findOne({
105
+ $or: [
106
+ { handle: `@${bareAcct}` },
107
+ { handle: bareAcct },
108
+ ],
109
+ });
110
+ if (follower) {
111
+ return res.json(
112
+ serializeAccount(
113
+ { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle },
114
+ { baseUrl },
115
+ ),
116
+ );
117
+ }
118
+
119
+ return res.status(404).json({ error: "Record not found" });
120
+ } catch (error) {
121
+ next(error);
122
+ }
123
+ });
124
+
125
+ // ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
126
+
127
+ router.get("/api/v1/accounts/:id", async (req, res, next) => {
128
+ try {
129
+ const { id } = req.params;
130
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
131
+ const collections = req.app.locals.mastodonCollections;
132
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
133
+ const handle = pluginOptions.handle || "user";
134
+
135
+ // Check if it's the local profile
136
+ const profile = await collections.ap_profile.findOne({});
137
+ if (profile && profile._id.toString() === id) {
138
+ return res.json(
139
+ serializeAccount(profile, { baseUrl, isLocal: true, handle }),
140
+ );
141
+ }
142
+
143
+ // Search known actors (followers, following, timeline authors)
144
+ // by checking if the deterministic hash matches
145
+ const follower = await collections.ap_followers
146
+ .find({})
147
+ .toArray();
148
+ for (const f of follower) {
149
+ if (remoteActorId(f.actorUrl) === id) {
150
+ return res.json(
151
+ serializeAccount(
152
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
153
+ { baseUrl },
154
+ ),
155
+ );
156
+ }
157
+ }
158
+
159
+ const following = await collections.ap_following
160
+ .find({})
161
+ .toArray();
162
+ for (const f of following) {
163
+ if (remoteActorId(f.actorUrl) === id) {
164
+ return res.json(
165
+ serializeAccount(
166
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
167
+ { baseUrl },
168
+ ),
169
+ );
170
+ }
171
+ }
172
+
173
+ // Try timeline authors — find any post whose author URL hashes to this ID
174
+ const timelineItems = await collections.ap_timeline
175
+ .find({ "author.url": { $exists: true } })
176
+ .project({ author: 1 })
177
+ .toArray();
178
+
179
+ const seenUrls = new Set();
180
+ for (const item of timelineItems) {
181
+ const authorUrl = item.author?.url;
182
+ if (!authorUrl || seenUrls.has(authorUrl)) continue;
183
+ seenUrls.add(authorUrl);
184
+ if (remoteActorId(authorUrl) === id) {
185
+ return res.json(
186
+ serializeAccount(item.author, { baseUrl }),
187
+ );
188
+ }
189
+ }
190
+
191
+ return res.status(404).json({ error: "Record not found" });
192
+ } catch (error) {
193
+ next(error);
194
+ }
195
+ });
196
+
197
+ // ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
198
+
199
+ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
200
+ try {
201
+ const { id } = req.params;
202
+ const collections = req.app.locals.mastodonCollections;
203
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
204
+ const limit = parseLimit(req.query.limit);
205
+
206
+ // Resolve account ID to an author URL
207
+ const actorUrl = await resolveActorUrl(id, collections);
208
+ if (!actorUrl) {
209
+ return res.status(404).json({ error: "Record not found" });
210
+ }
211
+
212
+ // Build filter for this author's posts
213
+ const baseFilter = {
214
+ "author.url": actorUrl,
215
+ isContext: { $ne: true },
216
+ };
217
+
218
+ // Mastodon filters
219
+ if (req.query.only_media === "true") {
220
+ baseFilter.$or = [
221
+ { "photo.0": { $exists: true } },
222
+ { "video.0": { $exists: true } },
223
+ { "audio.0": { $exists: true } },
224
+ ];
225
+ }
226
+ if (req.query.exclude_replies === "true") {
227
+ baseFilter.inReplyTo = { $exists: false };
228
+ }
229
+ if (req.query.exclude_reblogs === "true") {
230
+ baseFilter.type = { $ne: "boost" };
231
+ }
232
+ if (req.query.pinned === "true") {
233
+ baseFilter.pinned = true;
234
+ }
235
+
236
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
237
+ max_id: req.query.max_id,
238
+ min_id: req.query.min_id,
239
+ since_id: req.query.since_id,
240
+ });
241
+
242
+ let items = await collections.ap_timeline
243
+ .find(filter)
244
+ .sort(sort)
245
+ .limit(limit)
246
+ .toArray();
247
+
248
+ if (reverse) {
249
+ items.reverse();
250
+ }
251
+
252
+ // Load interaction state if authenticated
253
+ let favouritedIds = new Set();
254
+ let rebloggedIds = new Set();
255
+ let bookmarkedIds = new Set();
256
+
257
+ if (req.mastodonToken && collections.ap_interactions) {
258
+ const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
259
+ if (lookupUrls.length > 0) {
260
+ const interactions = await collections.ap_interactions
261
+ .find({ objectUrl: { $in: lookupUrls } })
262
+ .toArray();
263
+ for (const ix of interactions) {
264
+ if (ix.type === "like") favouritedIds.add(ix.objectUrl);
265
+ else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
266
+ else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
267
+ }
268
+ }
269
+ }
270
+
271
+ const statuses = items.map((item) =>
272
+ serializeStatus(item, {
273
+ baseUrl,
274
+ favouritedIds,
275
+ rebloggedIds,
276
+ bookmarkedIds,
277
+ pinnedIds: new Set(),
278
+ }),
279
+ );
280
+
281
+ setPaginationHeaders(res, req, items, limit);
282
+ res.json(statuses);
283
+ } catch (error) {
284
+ next(error);
285
+ }
286
+ });
287
+
288
+ // ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
289
+
290
+ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
291
+ try {
292
+ const { id } = req.params;
293
+ const collections = req.app.locals.mastodonCollections;
294
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
295
+ const limit = parseLimit(req.query.limit);
296
+ const profile = await collections.ap_profile.findOne({});
297
+
298
+ // Only serve followers for the local account
299
+ if (!profile || profile._id.toString() !== id) {
300
+ return res.json([]);
301
+ }
302
+
303
+ const followers = await collections.ap_followers
304
+ .find({})
305
+ .limit(limit)
306
+ .toArray();
307
+
308
+ const accounts = followers.map((f) =>
309
+ serializeAccount(
310
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
311
+ { baseUrl },
312
+ ),
313
+ );
314
+
315
+ res.json(accounts);
316
+ } catch (error) {
317
+ next(error);
318
+ }
319
+ });
320
+
321
+ // ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
322
+
323
+ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
324
+ try {
325
+ const { id } = req.params;
326
+ const collections = req.app.locals.mastodonCollections;
327
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
328
+ const limit = parseLimit(req.query.limit);
329
+ const profile = await collections.ap_profile.findOne({});
330
+
331
+ // Only serve following for the local account
332
+ if (!profile || profile._id.toString() !== id) {
333
+ return res.json([]);
334
+ }
335
+
336
+ const following = await collections.ap_following
337
+ .find({})
338
+ .limit(limit)
339
+ .toArray();
340
+
341
+ const accounts = following.map((f) =>
342
+ serializeAccount(
343
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
344
+ { baseUrl },
345
+ ),
346
+ );
347
+
348
+ res.json(accounts);
349
+ } catch (error) {
350
+ next(error);
351
+ }
352
+ });
353
+
354
+ // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
355
+
356
+ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
357
+ try {
358
+ // id[] can come as single value or array
359
+ let ids = req.query["id[]"] || req.query.id || [];
360
+ if (!Array.isArray(ids)) ids = [ids];
361
+
362
+ if (ids.length === 0) {
363
+ return res.json([]);
364
+ }
365
+
366
+ const collections = req.app.locals.mastodonCollections;
367
+
368
+ // Load all followers/following for efficient lookup
369
+ const [followers, following, blocked, muted] = await Promise.all([
370
+ collections.ap_followers.find({}).toArray(),
371
+ collections.ap_following.find({}).toArray(),
372
+ collections.ap_blocked.find({}).toArray(),
373
+ collections.ap_muted.find({}).toArray(),
374
+ ]);
375
+
376
+ const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
377
+ const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
378
+ const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
379
+ const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
380
+
381
+ const relationships = ids.map((id) => ({
382
+ id,
383
+ following: followingIds.has(id),
384
+ showing_reblogs: followingIds.has(id),
385
+ notifying: false,
386
+ languages: [],
387
+ followed_by: followerIds.has(id),
388
+ blocking: blockedIds.has(id),
389
+ blocked_by: false,
390
+ muting: mutedIds.has(id),
391
+ muting_notifications: mutedIds.has(id),
392
+ requested: false,
393
+ requested_by: false,
394
+ domain_blocking: false,
395
+ endorsed: false,
396
+ note: "",
397
+ }));
398
+
399
+ res.json(relationships);
400
+ } catch (error) {
401
+ next(error);
402
+ }
403
+ });
404
+
405
+ // ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
406
+
407
+ router.get("/api/v1/accounts/familiar_followers", (req, res) => {
408
+ // Stub — returns empty for each requested ID
409
+ let ids = req.query["id[]"] || req.query.id || [];
410
+ if (!Array.isArray(ids)) ids = [ids];
411
+ res.json(ids.map((id) => ({ id, accounts: [] })));
412
+ });
413
+
414
+ // ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
415
+
416
+ router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
417
+ try {
418
+ const token = req.mastodonToken;
419
+ if (!token) {
420
+ return res.status(401).json({ error: "The access token is invalid" });
421
+ }
422
+
423
+ const { id } = req.params;
424
+ const collections = req.app.locals.mastodonCollections;
425
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
426
+
427
+ // Resolve the account ID to an actor URL
428
+ const actorUrl = await resolveActorUrl(id, collections);
429
+ if (!actorUrl) {
430
+ return res.status(404).json({ error: "Record not found" });
431
+ }
432
+
433
+ // Use the plugin's followActor method
434
+ if (pluginOptions.followActor) {
435
+ const result = await pluginOptions.followActor(actorUrl);
436
+ if (!result.ok) {
437
+ return res.status(422).json({ error: result.error || "Follow failed" });
438
+ }
439
+ }
440
+
441
+ // Return relationship
442
+ const followingIds = new Set();
443
+ const following = await collections.ap_following.find({}).toArray();
444
+ for (const f of following) {
445
+ followingIds.add(remoteActorId(f.actorUrl));
446
+ }
447
+
448
+ const followerIds = new Set();
449
+ const followers = await collections.ap_followers.find({}).toArray();
450
+ for (const f of followers) {
451
+ followerIds.add(remoteActorId(f.actorUrl));
452
+ }
453
+
454
+ res.json({
455
+ id,
456
+ following: true,
457
+ showing_reblogs: true,
458
+ notifying: false,
459
+ languages: [],
460
+ followed_by: followerIds.has(id),
461
+ blocking: false,
462
+ blocked_by: false,
463
+ muting: false,
464
+ muting_notifications: false,
465
+ requested: false,
466
+ requested_by: false,
467
+ domain_blocking: false,
468
+ endorsed: false,
469
+ note: "",
470
+ });
471
+ } catch (error) {
472
+ next(error);
473
+ }
474
+ });
475
+
476
+ // ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
477
+
478
+ router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
479
+ try {
480
+ const token = req.mastodonToken;
481
+ if (!token) {
482
+ return res.status(401).json({ error: "The access token is invalid" });
483
+ }
484
+
485
+ const { id } = req.params;
486
+ const collections = req.app.locals.mastodonCollections;
487
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
488
+
489
+ const actorUrl = await resolveActorUrl(id, collections);
490
+ if (!actorUrl) {
491
+ return res.status(404).json({ error: "Record not found" });
492
+ }
493
+
494
+ if (pluginOptions.unfollowActor) {
495
+ const result = await pluginOptions.unfollowActor(actorUrl);
496
+ if (!result.ok) {
497
+ return res.status(422).json({ error: result.error || "Unfollow failed" });
498
+ }
499
+ }
500
+
501
+ const followerIds = new Set();
502
+ const followers = await collections.ap_followers.find({}).toArray();
503
+ for (const f of followers) {
504
+ followerIds.add(remoteActorId(f.actorUrl));
505
+ }
506
+
507
+ res.json({
508
+ id,
509
+ following: false,
510
+ showing_reblogs: true,
511
+ notifying: false,
512
+ languages: [],
513
+ followed_by: followerIds.has(id),
514
+ blocking: false,
515
+ blocked_by: false,
516
+ muting: false,
517
+ muting_notifications: false,
518
+ requested: false,
519
+ requested_by: false,
520
+ domain_blocking: false,
521
+ endorsed: false,
522
+ note: "",
523
+ });
524
+ } catch (error) {
525
+ next(error);
526
+ }
527
+ });
528
+
529
+ // ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
530
+
531
+ router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
532
+ try {
533
+ const token = req.mastodonToken;
534
+ if (!token) {
535
+ return res.status(401).json({ error: "The access token is invalid" });
536
+ }
537
+
538
+ const { id } = req.params;
539
+ const collections = req.app.locals.mastodonCollections;
540
+
541
+ const actorUrl = await resolveActorUrl(id, collections);
542
+ if (actorUrl && collections.ap_muted) {
543
+ await collections.ap_muted.updateOne(
544
+ { url: actorUrl },
545
+ { $set: { url: actorUrl, createdAt: new Date().toISOString() } },
546
+ { upsert: true },
547
+ );
548
+ }
549
+
550
+ res.json({
551
+ id,
552
+ following: false,
553
+ showing_reblogs: true,
554
+ notifying: false,
555
+ languages: [],
556
+ followed_by: false,
557
+ blocking: false,
558
+ blocked_by: false,
559
+ muting: true,
560
+ muting_notifications: true,
561
+ requested: false,
562
+ requested_by: false,
563
+ domain_blocking: false,
564
+ endorsed: false,
565
+ note: "",
566
+ });
567
+ } catch (error) {
568
+ next(error);
569
+ }
570
+ });
571
+
572
+ // ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
573
+
574
+ router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
575
+ try {
576
+ const token = req.mastodonToken;
577
+ if (!token) {
578
+ return res.status(401).json({ error: "The access token is invalid" });
579
+ }
580
+
581
+ const { id } = req.params;
582
+ const collections = req.app.locals.mastodonCollections;
583
+
584
+ const actorUrl = await resolveActorUrl(id, collections);
585
+ if (actorUrl && collections.ap_muted) {
586
+ await collections.ap_muted.deleteOne({ url: actorUrl });
587
+ }
588
+
589
+ res.json({
590
+ id,
591
+ following: false,
592
+ showing_reblogs: true,
593
+ notifying: false,
594
+ languages: [],
595
+ followed_by: false,
596
+ blocking: false,
597
+ blocked_by: false,
598
+ muting: false,
599
+ muting_notifications: false,
600
+ requested: false,
601
+ requested_by: false,
602
+ domain_blocking: false,
603
+ endorsed: false,
604
+ note: "",
605
+ });
606
+ } catch (error) {
607
+ next(error);
608
+ }
609
+ });
610
+
611
+ // ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
612
+
613
+ router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
614
+ try {
615
+ const token = req.mastodonToken;
616
+ if (!token) {
617
+ return res.status(401).json({ error: "The access token is invalid" });
618
+ }
619
+
620
+ const { id } = req.params;
621
+ const collections = req.app.locals.mastodonCollections;
622
+
623
+ const actorUrl = await resolveActorUrl(id, collections);
624
+ if (actorUrl && collections.ap_blocked) {
625
+ await collections.ap_blocked.updateOne(
626
+ { url: actorUrl },
627
+ { $set: { url: actorUrl, createdAt: new Date().toISOString() } },
628
+ { upsert: true },
629
+ );
630
+ }
631
+
632
+ res.json({
633
+ id,
634
+ following: false,
635
+ showing_reblogs: true,
636
+ notifying: false,
637
+ languages: [],
638
+ followed_by: false,
639
+ blocking: true,
640
+ blocked_by: false,
641
+ muting: false,
642
+ muting_notifications: false,
643
+ requested: false,
644
+ requested_by: false,
645
+ domain_blocking: false,
646
+ endorsed: false,
647
+ note: "",
648
+ });
649
+ } catch (error) {
650
+ next(error);
651
+ }
652
+ });
653
+
654
+ // ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
655
+
656
+ router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
657
+ try {
658
+ const token = req.mastodonToken;
659
+ if (!token) {
660
+ return res.status(401).json({ error: "The access token is invalid" });
661
+ }
662
+
663
+ const { id } = req.params;
664
+ const collections = req.app.locals.mastodonCollections;
665
+
666
+ const actorUrl = await resolveActorUrl(id, collections);
667
+ if (actorUrl && collections.ap_blocked) {
668
+ await collections.ap_blocked.deleteOne({ url: actorUrl });
669
+ }
670
+
671
+ res.json({
672
+ id,
673
+ following: false,
674
+ showing_reblogs: true,
675
+ notifying: false,
676
+ languages: [],
677
+ followed_by: false,
678
+ blocking: false,
679
+ blocked_by: false,
680
+ muting: false,
681
+ muting_notifications: false,
682
+ requested: false,
683
+ requested_by: false,
684
+ domain_blocking: false,
685
+ endorsed: false,
686
+ note: "",
687
+ });
688
+ } catch (error) {
689
+ next(error);
690
+ }
691
+ });
692
+
693
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
694
+
695
+ /**
696
+ * Resolve an account ID back to an actor URL by scanning followers/following.
697
+ */
698
+ async function resolveActorUrl(id, collections) {
699
+ // Check if it's the local profile
700
+ const profile = await collections.ap_profile.findOne({});
701
+ if (profile && profile._id.toString() === id) {
702
+ return profile.url;
703
+ }
704
+
705
+ // Check followers
706
+ const followers = await collections.ap_followers.find({}).toArray();
707
+ for (const f of followers) {
708
+ if (remoteActorId(f.actorUrl) === id) {
709
+ return f.actorUrl;
710
+ }
711
+ }
712
+
713
+ // Check following
714
+ const following = await collections.ap_following.find({}).toArray();
715
+ for (const f of following) {
716
+ if (remoteActorId(f.actorUrl) === id) {
717
+ return f.actorUrl;
718
+ }
719
+ }
720
+
721
+ // Check timeline authors
722
+ const timelineItems = await collections.ap_timeline
723
+ .find({ "author.url": { $exists: true } })
724
+ .project({ "author.url": 1 })
725
+ .toArray();
726
+
727
+ const seenUrls = new Set();
728
+ for (const item of timelineItems) {
729
+ const authorUrl = item.author?.url;
730
+ if (!authorUrl || seenUrls.has(authorUrl)) continue;
731
+ seenUrls.add(authorUrl);
732
+ if (remoteActorId(authorUrl) === id) {
733
+ return authorUrl;
734
+ }
735
+ }
736
+
737
+ return null;
738
+ }
739
+
740
+ export default router;