@rmdes/indiekit-endpoint-activitypub 3.13.10 → 3.13.12

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/index.js CHANGED
@@ -1,171 +1,44 @@
1
1
  import express from "express";
2
2
  import { waitForReady } from "@rmdes/indiekit-startup-gate";
3
3
  import { ACTIVITYPUB_BLOCKS } from "./lib/blocks.js";
4
+ import { resolveOptions } from "./lib/defaults.js";
5
+ import { buildNavigationItems } from "./lib/navigation.js";
4
6
 
5
- import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
7
+ import { setupFederation } from "./lib/federation-setup.js";
6
8
  import { createMastodonRouter } from "./lib/mastodon/router.js";
7
9
  import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
8
10
  import { initRedisCache } from "./lib/redis-cache.js";
9
11
  import { createIndexes } from "./lib/init-indexes.js";
10
12
  import { lookupWithSecurity } from "./lib/lookup-helpers.js";
11
- import {
12
- needsDirectFollow,
13
- sendDirectFollow,
14
- sendDirectUnfollow,
15
- } from "./lib/direct-follow.js";
16
13
  import {
17
14
  createFedifyMiddleware,
18
15
  } from "./lib/federation-bridge.js";
19
- import {
20
- jf2ToActivityStreams,
21
- jf2ToAS2Activity,
22
- } from "./lib/jf2-to-as2.js";
23
16
  import { createSyndicator } from "./lib/syndicator.js";
24
- import { dashboardController } from "./lib/controllers/dashboard.js";
25
- import {
26
- readerController,
27
- notificationsController,
28
- markAllNotificationsReadController,
29
- clearAllNotificationsController,
30
- deleteNotificationController,
31
- composeController,
32
- submitComposeController,
33
- remoteProfileController,
34
- followController,
35
- unfollowController,
36
- postDetailController,
37
- } from "./lib/controllers/reader.js";
38
- import {
39
- likeController,
40
- unlikeController,
41
- boostController,
42
- unboostController,
43
- } from "./lib/controllers/interactions.js";
44
- import {
45
- muteController,
46
- unmuteController,
47
- blockController,
48
- unblockController,
49
- blockServerController,
50
- unblockServerController,
51
- moderationController,
52
- filterModeController,
53
- } from "./lib/controllers/moderation.js";
54
- import { followersController } from "./lib/controllers/followers.js";
55
- import {
56
- approveFollowController,
57
- rejectFollowController,
58
- } from "./lib/controllers/follow-requests.js";
59
- import { followingController } from "./lib/controllers/following.js";
60
- import { activitiesController } from "./lib/controllers/activities.js";
61
- import {
62
- migrateGetController,
63
- migratePostController,
64
- migrateImportController,
65
- } from "./lib/controllers/migrate.js";
66
- import {
67
- profileGetController,
68
- profilePostController,
69
- } from "./lib/controllers/profile.js";
70
- import {
71
- featuredGetController,
72
- featuredPinController,
73
- featuredUnpinController,
74
- } from "./lib/controllers/featured.js";
75
- import {
76
- featuredTagsGetController,
77
- featuredTagsAddController,
78
- featuredTagsRemoveController,
79
- } from "./lib/controllers/featured-tags.js";
80
- import { resolveController } from "./lib/controllers/resolve.js";
81
- import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
82
- import { apiTimelineController, countNewController, markReadController } from "./lib/controllers/api-timeline.js";
83
17
  import {
84
- exploreController,
85
- exploreApiController,
86
- instanceSearchApiController,
87
- instanceCheckApiController,
88
- popularAccountsApiController,
89
- } from "./lib/controllers/explore.js";
90
- import {
91
- followTagController,
92
- unfollowTagController,
93
- followTagGloballyController,
94
- unfollowTagGloballyController,
95
- } from "./lib/controllers/follow-tag.js";
96
- import {
97
- listTabsController,
98
- addTabController,
99
- removeTabController,
100
- reorderTabsController,
101
- } from "./lib/controllers/tabs.js";
102
- import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js";
103
- import { publicProfileController } from "./lib/controllers/public-profile.js";
104
- import {
105
- messagesController,
106
- messageComposeController,
107
- submitMessageController,
108
- markAllMessagesReadController,
109
- clearAllMessagesController,
110
- deleteMessageController,
111
- } from "./lib/controllers/messages.js";
112
- import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
113
- import { myProfileController } from "./lib/controllers/my-profile.js";
114
- import {
115
- refollowPauseController,
116
- refollowResumeController,
117
- refollowStatusController,
118
- } from "./lib/controllers/refollow.js";
18
+ loadRsaPrivateKey,
19
+ followActor,
20
+ unfollowActor,
21
+ broadcastActorUpdate,
22
+ broadcastDelete,
23
+ broadcastPostUpdate,
24
+ deletePost,
25
+ updatePost,
26
+ getActorUrl,
27
+ } from "./lib/endpoint-federation.js";
28
+ import { buildAdminRoutes } from "./lib/routes/admin-routes.js";
29
+ import { buildRoutesPublic, buildContentNegotiationRoutes } from "./lib/routes/public-routes.js";
119
30
  import { startBatchRefollow } from "./lib/batch-refollow.js";
120
- import { logActivity } from "./lib/activity-log.js";
121
- import { batchBroadcast } from "./lib/batch-broadcast.js";
122
31
  import { scheduleCleanup } from "./lib/timeline-cleanup.js";
123
32
  import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
124
33
  import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
125
34
  import { scheduleKeyRefresh } from "./lib/key-refresh.js";
126
35
  import { startInboxProcessor } from "./lib/inbox-queue.js";
127
- import { deleteFederationController } from "./lib/controllers/federation-delete.js";
128
- import {
129
- federationMgmtController,
130
- rebroadcastController,
131
- viewApJsonController,
132
- broadcastActorUpdateController,
133
- lookupObjectController,
134
- } from "./lib/controllers/federation-mgmt.js";
135
- import {
136
- settingsGetController,
137
- settingsPostController,
138
- } from "./lib/controllers/settings.js";
139
-
140
- const defaults = {
141
- mountPath: "/activitypub",
142
- actor: {
143
- handle: "rick",
144
- name: "",
145
- summary: "",
146
- icon: "",
147
- },
148
- checked: true,
149
- alsoKnownAs: "",
150
- activityRetentionDays: 90,
151
- storeRawActivities: false,
152
- redisUrl: "",
153
- parallelWorkers: 5,
154
- actorType: "Person",
155
- logLevel: "warning",
156
- timelineRetention: 1000,
157
- notificationRetentionDays: 30,
158
- debugDashboard: false,
159
- debugPassword: "",
160
- defaultVisibility: "public", // "public" | "unlisted" | "followers"
161
- };
162
36
 
163
37
  export default class ActivityPubEndpoint {
164
38
  name = "ActivityPub endpoint";
165
39
 
166
40
  constructor(options = {}) {
167
- this.options = { ...defaults, ...options };
168
- this.options.actor = { ...defaults.actor, ...options.actor };
41
+ this.options = resolveOptions(options);
169
42
  this.mountPath = this.options.mountPath;
170
43
 
171
44
  this._publicationUrl = "";
@@ -179,48 +52,7 @@ export default class ActivityPubEndpoint {
179
52
  }
180
53
 
181
54
  get navigationItems() {
182
- return [
183
- {
184
- href: this.options.mountPath,
185
- text: "activitypub.title",
186
- requiresDatabase: true,
187
- },
188
- {
189
- href: `${this.options.mountPath}/admin/reader`,
190
- text: "activitypub.reader.title",
191
- requiresDatabase: true,
192
- },
193
- {
194
- href: `${this.options.mountPath}/admin/reader/notifications`,
195
- text: "activitypub.notifications.title",
196
- requiresDatabase: true,
197
- },
198
- {
199
- href: `${this.options.mountPath}/admin/reader/messages`,
200
- text: "activitypub.messages.title",
201
- requiresDatabase: true,
202
- },
203
- {
204
- href: `${this.options.mountPath}/admin/reader/moderation`,
205
- text: "activitypub.moderation.title",
206
- requiresDatabase: true,
207
- },
208
- {
209
- href: `${this.options.mountPath}/admin/my-profile`,
210
- text: "activitypub.myProfile.title",
211
- requiresDatabase: true,
212
- },
213
- {
214
- href: `${this.options.mountPath}/admin/federation`,
215
- text: "activitypub.federationMgmt.title",
216
- requiresDatabase: true,
217
- },
218
- {
219
- href: `${this.options.mountPath}/admin/settings`,
220
- text: "activitypub.settings.title",
221
- requiresDatabase: true,
222
- },
223
- ];
55
+ return buildNavigationItems(this.options.mountPath);
224
56
  }
225
57
 
226
58
  /**
@@ -244,159 +76,14 @@ export default class ActivityPubEndpoint {
244
76
  * Fedify handles actor, inbox, outbox, followers, following.
245
77
  */
246
78
  get routesPublic() {
247
- const router = express.Router(); // eslint-disable-line new-cap
248
- const self = this;
249
-
250
- router.use((req, res, next) => {
251
- if (!self._fedifyMiddleware) return next();
252
- // Skip Fedify for admin UI routes — they're handled by the
253
- // authenticated `routes` getter, not the federation layer.
254
- if (req.path.startsWith("/admin")) return next();
255
-
256
- // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
257
- // (it only returns true for explicit application/activity+json etc.).
258
- // Remote servers fetching actor URLs for HTTP Signature verification
259
- // (e.g. tags.pub) often omit Accept or use */* — they get HTML back
260
- // instead of the actor JSON, causing "public key not found" errors.
261
- // Fix: for GET requests to actor paths, upgrade ambiguous Accept headers
262
- // to application/activity+json so Fedify serves JSON-LD. Explicit
263
- // text/html requests (browsers) are unaffected.
264
- if (req.method === "GET" && /^\/users\/[^/]+\/?$/.test(req.path)) {
265
- const accept = req.get("accept") || "";
266
- if (!accept.includes("text/html") && !accept.includes("application/xhtml+xml")) {
267
- req.headers["accept"] = "application/activity+json";
268
- }
269
- }
270
-
271
- return self._fedifyMiddleware(req, res, next);
272
- });
273
-
274
- // Authorize interaction — remote follow / subscribe endpoint.
275
- // Remote servers redirect users here via the WebFinger subscribe template.
276
- router.get("/authorize_interaction", authorizeInteractionController(self));
277
-
278
- // HTML fallback for actor URL — serve a public profile page.
279
- // Fedify only serves JSON-LD; browsers get 406 and fall through here.
280
- router.get("/users/:identifier", publicProfileController(self));
281
-
282
- // Catch-all for federation paths that Fedify didn't handle (e.g. GET
283
- // on inbox). Without this, they fall through to Indiekit's auth
284
- // middleware and redirect to the login page.
285
- router.all("/users/:identifier/inbox", (req, res) => {
286
- res
287
- .status(405)
288
- .set("Allow", "POST")
289
- .type("application/activity+json")
290
- .json({
291
- error: "Method Not Allowed",
292
- message: "The inbox only accepts POST requests",
293
- });
294
- });
295
- router.all("/inbox", (req, res) => {
296
- res
297
- .status(405)
298
- .set("Allow", "POST")
299
- .type("application/activity+json")
300
- .json({
301
- error: "Method Not Allowed",
302
- message: "The shared inbox only accepts POST requests",
303
- });
304
- });
305
-
306
- return router;
79
+ return buildRoutesPublic(this);
307
80
  }
308
81
 
309
82
  /**
310
83
  * Authenticated admin routes — mounted at mountPath, behind IndieAuth.
311
84
  */
312
85
  get routes() {
313
- const router = express.Router(); // eslint-disable-line new-cap
314
- const mp = this.options.mountPath;
315
-
316
- router.get("/", dashboardController(mp));
317
- router.get("/admin/reader", readerController(mp));
318
- router.get("/admin/reader/tag", tagTimelineController(mp));
319
- router.get("/admin/reader/api/timeline", apiTimelineController(mp));
320
- router.get("/admin/reader/api/timeline/count-new", countNewController());
321
- router.post("/admin/reader/api/timeline/mark-read", markReadController());
322
- router.get("/admin/reader/explore", exploreController(mp));
323
- router.get("/admin/reader/api/explore", exploreApiController(mp));
324
- router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp));
325
- router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
326
- router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
327
- router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
328
- router.get("/admin/reader/api/tabs", listTabsController(mp));
329
- router.post("/admin/reader/api/tabs", addTabController(mp));
330
- router.post("/admin/reader/api/tabs/remove", removeTabController(mp));
331
- router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
332
- router.post("/admin/reader/follow-tag", followTagController(mp));
333
- router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
334
- router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this));
335
- router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this));
336
- router.get("/admin/reader/notifications", notificationsController(mp));
337
- router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
338
- router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
339
- router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
340
- router.get("/admin/reader/messages", messagesController(mp));
341
- router.get("/admin/reader/messages/compose", messageComposeController(mp, this));
342
- router.post("/admin/reader/messages/compose", submitMessageController(mp, this));
343
- router.post("/admin/reader/messages/mark-read", markAllMessagesReadController(mp));
344
- router.post("/admin/reader/messages/clear", clearAllMessagesController(mp));
345
- router.post("/admin/reader/messages/delete", deleteMessageController(mp));
346
- router.get("/admin/reader/compose", composeController(mp, this));
347
- router.post("/admin/reader/compose", submitComposeController(mp, this));
348
- router.post("/admin/reader/like", likeController(mp, this));
349
- router.post("/admin/reader/unlike", unlikeController(mp, this));
350
- router.post("/admin/reader/boost", boostController(mp, this));
351
- router.post("/admin/reader/unboost", unboostController(mp, this));
352
- router.get("/admin/reader/resolve", resolveController(mp, this));
353
- router.get("/admin/reader/profile", remoteProfileController(mp, this));
354
- router.get("/admin/reader/post", postDetailController(mp, this));
355
- router.post("/admin/reader/follow", followController(mp, this));
356
- router.post("/admin/reader/unfollow", unfollowController(mp, this));
357
- router.get("/admin/reader/moderation", moderationController(mp));
358
- router.post("/admin/reader/moderation/filter-mode", filterModeController(mp));
359
- router.post("/admin/reader/mute", muteController(mp, this));
360
- router.post("/admin/reader/unmute", unmuteController(mp, this));
361
- router.post("/admin/reader/block", blockController(mp, this));
362
- router.post("/admin/reader/unblock", unblockController(mp, this));
363
- router.post("/admin/reader/block-server", blockServerController(mp));
364
- router.post("/admin/reader/unblock-server", unblockServerController(mp));
365
- router.get("/admin/followers", followersController(mp));
366
- router.post("/admin/followers/approve", approveFollowController(mp, this));
367
- router.post("/admin/followers/reject", rejectFollowController(mp, this));
368
- router.get("/admin/following", followingController(mp));
369
- router.get("/admin/activities", activitiesController(mp));
370
- router.get("/admin/featured", featuredGetController(mp));
371
- router.post("/admin/featured/pin", featuredPinController(mp, this));
372
- router.post("/admin/featured/unpin", featuredUnpinController(mp, this));
373
- router.get("/admin/tags", featuredTagsGetController(mp));
374
- router.post("/admin/tags/add", featuredTagsAddController(mp, this));
375
- router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this));
376
- router.get("/admin/profile", profileGetController(mp));
377
- router.post("/admin/profile", profilePostController(mp, this));
378
- router.get("/admin/my-profile", myProfileController(this));
379
- router.get("/admin/migrate", migrateGetController(mp, this.options));
380
- router.post("/admin/migrate", migratePostController(mp, this.options));
381
- router.post(
382
- "/admin/migrate/import",
383
- migrateImportController(mp, this.options),
384
- );
385
- router.post("/admin/refollow/pause", refollowPauseController(mp, this));
386
- router.post("/admin/refollow/resume", refollowResumeController(mp, this));
387
- router.get("/admin/refollow/status", refollowStatusController(mp));
388
- router.post("/admin/federation/delete", deleteFederationController(mp, this));
389
- router.get("/admin/federation", federationMgmtController(mp, this));
390
- router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this));
391
- router.get("/admin/federation/ap-json", viewApJsonController(mp, this));
392
- router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
393
- router.get("/admin/federation/lookup", lookupObjectController(mp, this));
394
-
395
- // Settings
396
- router.get("/admin/settings", settingsGetController(mp));
397
- router.post("/admin/settings", settingsPostController(mp));
398
-
399
- return router;
86
+ return buildAdminRoutes(this);
400
87
  }
401
88
 
402
89
  /**
@@ -405,95 +92,7 @@ export default class ActivityPubEndpoint {
405
92
  * at /nodeinfo/2.1 (delegated to Fedify).
406
93
  */
407
94
  get contentNegotiationRoutes() {
408
- const router = express.Router(); // eslint-disable-line new-cap
409
- const self = this;
410
-
411
- // Let Fedify handle NodeInfo data (/nodeinfo/2.1)
412
- // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
413
- // Fedify here, because fromExpressRequest() consumes the body stream,
414
- // breaking Express body-parsed routes downstream (e.g. admin forms).
415
- router.use((req, res, next) => {
416
- if (!self._fedifyMiddleware) return next();
417
- if (req.method !== "GET" && req.method !== "HEAD") return next();
418
- // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1).
419
- // All other paths in this root-mounted router are handled by the
420
- // content negotiation catch-all below. Passing arbitrary paths like
421
- // /notes/... to Fedify causes harmless but noisy 404 warnings.
422
- if (!req.path.startsWith("/nodeinfo/")) return next();
423
- return self._fedifyMiddleware(req, res, next);
424
- });
425
-
426
- // Content negotiation for AP clients on regular URLs
427
- router.get("{*path}", async (req, res, next) => {
428
- const accept = req.headers.accept || "";
429
- const isActivityPub =
430
- accept.includes("application/activity+json") ||
431
- accept.includes("application/ld+json");
432
-
433
- if (!isActivityPub) {
434
- return next();
435
- }
436
-
437
- try {
438
- // Root URL — redirect to Fedify actor
439
- if (req.path === "/") {
440
- const actorPath = `${self.options.mountPath}/users/${self.options.actor.handle}`;
441
- return res.redirect(actorPath);
442
- }
443
-
444
- // Post URLs — look up in database and convert to AS2
445
- const { application } = req.app.locals;
446
- const postsCollection = application?.collections?.get("posts");
447
- if (!postsCollection) {
448
- return next();
449
- }
450
-
451
- const requestUrl = `${self._publicationUrl}${req.path.slice(1)}`;
452
- const post = await postsCollection.findOne({
453
- "properties.url": requestUrl,
454
- });
455
-
456
- if (!post || post.properties?.deleted) {
457
- // FEP-4f05: Serve Tombstone for deleted posts
458
- const { getTombstone } = await import("./lib/storage/tombstones.js");
459
- const tombstone = await getTombstone(self._collections, requestUrl);
460
- if (tombstone) {
461
- res.status(410).set("Content-Type", "application/activity+json").json({
462
- "@context": "https://www.w3.org/ns/activitystreams",
463
- type: "Tombstone",
464
- id: requestUrl,
465
- formerType: tombstone.formerType,
466
- published: tombstone.published || undefined,
467
- deleted: tombstone.deleted,
468
- });
469
- return;
470
- }
471
- return next();
472
- }
473
-
474
- const actorUrl = self._getActorUrl();
475
- const activity = jf2ToActivityStreams(
476
- post.properties,
477
- actorUrl,
478
- self._publicationUrl,
479
- { visibility: self.options.defaultVisibility },
480
- );
481
-
482
- const object = activity.object || activity;
483
- res.set("Content-Type", "application/activity+json");
484
- return res.json({
485
- "@context": [
486
- "https://www.w3.org/ns/activitystreams",
487
- "https://w3id.org/security/v1",
488
- ],
489
- ...object,
490
- });
491
- } catch {
492
- return next();
493
- }
494
- });
495
-
496
- return router;
95
+ return buildContentNegotiationRoutes(this);
497
96
  }
498
97
 
499
98
  /**
@@ -517,141 +116,11 @@ export default class ActivityPubEndpoint {
517
116
  * @returns {Promise<CryptoKey|null>}
518
117
  */
519
118
  async _loadRsaPrivateKey() {
520
- try {
521
- const keyDoc = await this._collections.ap_keys.findOne({
522
- privateKeyPem: { $exists: true },
523
- });
524
- if (!keyDoc?.privateKeyPem) return null;
525
- const pemBody = keyDoc.privateKeyPem
526
- .replace(/-----[^-]+-----/g, "")
527
- .replace(/\s/g, "");
528
- return await crypto.subtle.importKey(
529
- "pkcs8",
530
- Buffer.from(pemBody, "base64"),
531
- { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
532
- true,
533
- ["sign"],
534
- );
535
- } catch (error) {
536
- console.error("[ActivityPub] Failed to load RSA key:", error.message);
537
- return null;
538
- }
119
+ return loadRsaPrivateKey(this);
539
120
  }
540
121
 
541
122
  async followActor(actorUrl, actorInfo = {}) {
542
- if (!this._federation) {
543
- return { ok: false, error: "Federation not initialized" };
544
- }
545
-
546
- try {
547
- const { Follow } = await import("@fedify/fedify/vocab");
548
- const handle = this.options.actor.handle;
549
- const ctx = this._federation.createContext(
550
- new URL(this._publicationUrl),
551
- { handle, publicationUrl: this._publicationUrl },
552
- );
553
-
554
- // Resolve the remote actor to get their inbox
555
- // lookupWithSecurity handles signed→unsigned fallback automatically
556
- const documentLoader = await ctx.getDocumentLoader({
557
- identifier: handle,
558
- });
559
- const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
560
- documentLoader,
561
- });
562
- if (!remoteActor) {
563
- return { ok: false, error: "Could not resolve remote actor" };
564
- }
565
-
566
- // Send Follow activity
567
- if (needsDirectFollow(actorUrl)) {
568
- // tags.pub rejects Fedify's LD Signature context (identity/v1).
569
- // Send a minimal signed Follow directly, bypassing the outbox pipeline.
570
- // See: https://github.com/social-web-foundation/tags.pub/issues/10
571
- const rsaKey = await this._loadRsaPrivateKey();
572
- if (!rsaKey) {
573
- return { ok: false, error: "No RSA key available for direct follow" };
574
- }
575
- const result = await sendDirectFollow({
576
- actorUri: ctx.getActorUri(handle).href,
577
- targetActorUrl: actorUrl,
578
- inboxUrl: remoteActor.inboxId?.href,
579
- keyId: `${ctx.getActorUri(handle).href}#main-key`,
580
- privateKey: rsaKey,
581
- });
582
- if (!result.ok) {
583
- return { ok: false, error: result.error };
584
- }
585
- } else {
586
- const follow = new Follow({
587
- actor: ctx.getActorUri(handle),
588
- object: new URL(actorUrl),
589
- });
590
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
591
- orderingKey: actorUrl,
592
- });
593
- }
594
-
595
- // Store in ap_following
596
- const name =
597
- actorInfo.name ||
598
- remoteActor.name?.toString() ||
599
- remoteActor.preferredUsername?.toString() ||
600
- actorUrl;
601
- const actorHandle =
602
- actorInfo.handle ||
603
- remoteActor.preferredUsername?.toString() ||
604
- "";
605
- const avatar =
606
- actorInfo.photo ||
607
- (remoteActor.icon
608
- ? (await remoteActor.icon)?.url?.href || ""
609
- : "");
610
- const inbox = remoteActor.inboxId?.href || "";
611
- const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
612
-
613
- await this._collections.ap_following.updateOne(
614
- { actorUrl },
615
- {
616
- $set: {
617
- actorUrl,
618
- handle: actorHandle,
619
- name,
620
- avatar,
621
- inbox,
622
- sharedInbox,
623
- followedAt: new Date().toISOString(),
624
- source: "reader",
625
- },
626
- },
627
- { upsert: true },
628
- );
629
-
630
- console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
631
-
632
- await logActivity(this._collections.ap_activities, {
633
- direction: "outbound",
634
- type: "Follow",
635
- actorUrl: this._publicationUrl,
636
- objectUrl: actorUrl,
637
- actorName: name,
638
- summary: `Sent Follow to ${name} (${actorUrl})`,
639
- });
640
-
641
- return { ok: true };
642
- } catch (error) {
643
- console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
644
-
645
- await logActivity(this._collections.ap_activities, {
646
- direction: "outbound",
647
- type: "Follow",
648
- actorUrl: this._publicationUrl,
649
- objectUrl: actorUrl,
650
- summary: `Follow failed for ${actorUrl}: ${error.message}`,
651
- }).catch(() => {});
652
-
653
- return { ok: false, error: error.message };
654
- }
123
+ return followActor(this, actorUrl, actorInfo);
655
124
  }
656
125
 
657
126
  /**
@@ -660,277 +129,31 @@ export default class ActivityPubEndpoint {
660
129
  * @returns {Promise<{ok: boolean, error?: string}>}
661
130
  */
662
131
  async unfollowActor(actorUrl) {
663
- if (!this._federation) {
664
- return { ok: false, error: "Federation not initialized" };
665
- }
666
-
667
- try {
668
- const { Follow, Undo } = await import("@fedify/fedify/vocab");
669
- const handle = this.options.actor.handle;
670
- const ctx = this._federation.createContext(
671
- new URL(this._publicationUrl),
672
- { handle, publicationUrl: this._publicationUrl },
673
- );
674
-
675
- // Use authenticated document loader for servers requiring Authorized Fetch
676
- const documentLoader = await ctx.getDocumentLoader({
677
- identifier: handle,
678
- });
679
- const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
680
- documentLoader,
681
- });
682
- if (!remoteActor) {
683
- // Even if we can't resolve, remove locally
684
- await this._collections.ap_following.deleteOne({ actorUrl });
685
-
686
- await logActivity(this._collections.ap_activities, {
687
- direction: "outbound",
688
- type: "Undo(Follow)",
689
- actorUrl: this._publicationUrl,
690
- objectUrl: actorUrl,
691
- summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
692
- }).catch(() => {});
693
-
694
- return { ok: true };
695
- }
696
-
697
- if (needsDirectFollow(actorUrl)) {
698
- // tags.pub rejects Fedify's LD Signature context (identity/v1).
699
- // See: https://github.com/social-web-foundation/tags.pub/issues/10
700
- const rsaKey = await this._loadRsaPrivateKey();
701
- if (rsaKey) {
702
- const result = await sendDirectUnfollow({
703
- actorUri: ctx.getActorUri(handle).href,
704
- targetActorUrl: actorUrl,
705
- inboxUrl: remoteActor.inboxId?.href,
706
- keyId: `${ctx.getActorUri(handle).href}#main-key`,
707
- privateKey: rsaKey,
708
- });
709
- if (!result.ok) {
710
- console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
711
- }
712
- }
713
- } else {
714
- const follow = new Follow({
715
- actor: ctx.getActorUri(handle),
716
- object: new URL(actorUrl),
717
- });
718
- const undo = new Undo({
719
- actor: ctx.getActorUri(handle),
720
- object: follow,
721
- });
722
- await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
723
- orderingKey: actorUrl,
724
- });
725
- }
726
- await this._collections.ap_following.deleteOne({ actorUrl });
727
-
728
- console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
729
-
730
- await logActivity(this._collections.ap_activities, {
731
- direction: "outbound",
732
- type: "Undo(Follow)",
733
- actorUrl: this._publicationUrl,
734
- objectUrl: actorUrl,
735
- summary: `Sent Undo(Follow) to ${actorUrl}`,
736
- });
737
-
738
- return { ok: true };
739
- } catch (error) {
740
- console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
741
-
742
- await logActivity(this._collections.ap_activities, {
743
- direction: "outbound",
744
- type: "Undo(Follow)",
745
- actorUrl: this._publicationUrl,
746
- objectUrl: actorUrl,
747
- summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
748
- }).catch(() => {});
749
-
750
- // Remove locally even if remote delivery fails
751
- await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
752
- return { ok: false, error: error.message };
753
- }
132
+ return unfollowActor(this, actorUrl);
754
133
  }
755
134
 
756
- /**
757
- * Send an Update(Person) activity to all followers so remote servers
758
- * re-fetch the actor object (picking up profile changes, new featured
759
- * collections, attachments, etc.).
760
- *
761
- * Delivery is batched to avoid a thundering herd: hundreds of remote
762
- * servers simultaneously re-fetching the actor, featured posts, and
763
- * featured tags after receiving the Update all at once.
764
- */
765
135
  async broadcastActorUpdate() {
766
- if (!this._federation) return;
767
-
768
- try {
769
- const { Update } = await import("@fedify/fedify/vocab");
770
- const handle = this.options.actor.handle;
771
- const ctx = this._federation.createContext(
772
- new URL(this._publicationUrl),
773
- { handle, publicationUrl: this._publicationUrl },
774
- );
775
-
776
- const actor = await buildPersonActor(
777
- ctx,
778
- handle,
779
- this._collections,
780
- this.options.actorType,
781
- );
782
- if (!actor) {
783
- console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
784
- return;
785
- }
786
-
787
- const update = new Update({
788
- actor: ctx.getActorUri(handle),
789
- object: actor,
790
- });
791
-
792
- await batchBroadcast({
793
- federation: this._federation,
794
- collections: this._collections,
795
- publicationUrl: this._publicationUrl,
796
- handle,
797
- activity: update,
798
- label: "Update(Person)",
799
- objectUrl: this._getActorUrl(),
800
- });
801
- } catch (error) {
802
- console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
803
- }
136
+ return broadcastActorUpdate(this);
804
137
  }
805
138
 
806
- /**
807
- * Send Delete activity to all followers for a removed post.
808
- * Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup.
809
- * @param {string} postUrl - Full URL of the deleted post
810
- */
811
139
  async broadcastDelete(postUrl) {
812
- if (!this._federation) return;
813
-
814
- try {
815
- const { Delete } = await import("@fedify/fedify/vocab");
816
- const handle = this.options.actor.handle;
817
- const ctx = this._federation.createContext(
818
- new URL(this._publicationUrl),
819
- { handle, publicationUrl: this._publicationUrl },
820
- );
821
-
822
- const del = new Delete({
823
- actor: ctx.getActorUri(handle),
824
- object: new URL(postUrl),
825
- });
826
-
827
- await batchBroadcast({
828
- federation: this._federation,
829
- collections: this._collections,
830
- publicationUrl: this._publicationUrl,
831
- handle,
832
- activity: del,
833
- label: "Delete",
834
- objectUrl: postUrl,
835
- });
836
- } catch (error) {
837
- console.warn("[ActivityPub] broadcastDelete failed:", error.message);
838
- }
140
+ return broadcastDelete(this, postUrl);
839
141
  }
840
142
 
841
- /**
842
- * Called by post-content.js when a Micropub delete succeeds.
843
- * Broadcasts an ActivityPub Delete activity to all followers.
844
- * @param {string} url - Full URL of the deleted post
845
- */
846
143
  async delete(url) {
847
- // Record tombstone for FEP-4f05
848
- try {
849
- const { addTombstone } = await import("./lib/storage/tombstones.js");
850
- const postsCol = this._collections.posts;
851
- const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
852
- await addTombstone(this._collections, {
853
- url,
854
- formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
855
- published: post?.properties?.published || null,
856
- deleted: new Date().toISOString(),
857
- });
858
- } catch (error) {
859
- console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
860
- }
861
-
862
- await this.broadcastDelete(url).catch((err) =>
863
- console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
864
- );
144
+ return deletePost(this, url);
865
145
  }
866
146
 
867
- /**
868
- * Called by post-content.js when a Micropub update succeeds.
869
- * Broadcasts an ActivityPub Update activity for the post to all followers.
870
- * @param {object} properties - JF2 post properties (must include url)
871
- */
872
147
  async update(properties) {
873
- await this.broadcastPostUpdate(properties).catch((err) =>
874
- console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
875
- );
148
+ return updatePost(this, properties);
876
149
  }
877
150
 
878
- /**
879
- * Send an Update activity to all followers for a modified post.
880
- * Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
881
- * @param {object} properties - JF2 post properties
882
- */
883
151
  async broadcastPostUpdate(properties) {
884
- if (!this._federation) return;
885
-
886
- try {
887
- const { Update } = await import("@fedify/fedify/vocab");
888
- const actorUrl = this._getActorUrl();
889
- const handle = this.options.actor.handle;
890
- const ctx = this._federation.createContext(
891
- new URL(this._publicationUrl),
892
- { handle, publicationUrl: this._publicationUrl },
893
- );
894
-
895
- const createActivity = jf2ToAS2Activity(
896
- properties,
897
- actorUrl,
898
- this._publicationUrl,
899
- { visibility: this.options.defaultVisibility },
900
- );
901
-
902
- if (!createActivity) {
903
- console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
904
- return;
905
- }
906
-
907
- const noteObject = await createActivity.getObject();
908
- const activity = new Update({
909
- actor: ctx.getActorUri(handle),
910
- object: noteObject,
911
- });
912
-
913
- await batchBroadcast({
914
- federation: this._federation,
915
- collections: this._collections,
916
- publicationUrl: this._publicationUrl,
917
- handle,
918
- activity,
919
- label: "Update(Note)",
920
- objectUrl: properties.url,
921
- });
922
- } catch (error) {
923
- console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
924
- }
152
+ return broadcastPostUpdate(this, properties);
925
153
  }
926
154
 
927
- /**
928
- * Build the full actor URL from config.
929
- * @returns {string}
930
- */
931
155
  _getActorUrl() {
932
- const base = this._publicationUrl.replace(/\/$/, "");
933
- return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`;
156
+ return getActorUrl(this);
934
157
  }
935
158
 
936
159
  init(Indiekit) {