@rmdes/indiekit-endpoint-activitypub 3.13.9 → 3.13.11

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,6 +1,8 @@
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
7
  import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
6
8
  import { createMastodonRouter } from "./lib/mastodon/router.js";
@@ -16,106 +18,10 @@ import {
16
18
  import {
17
19
  createFedifyMiddleware,
18
20
  } from "./lib/federation-bridge.js";
19
- import {
20
- jf2ToActivityStreams,
21
- jf2ToAS2Activity,
22
- } from "./lib/jf2-to-as2.js";
21
+ import { jf2ToAS2Activity } from "./lib/jf2-to-as2.js";
23
22
  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
- 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";
23
+ import { buildAdminRoutes } from "./lib/routes/admin-routes.js";
24
+ import { buildRoutesPublic, buildContentNegotiationRoutes } from "./lib/routes/public-routes.js";
119
25
  import { startBatchRefollow } from "./lib/batch-refollow.js";
120
26
  import { logActivity } from "./lib/activity-log.js";
121
27
  import { batchBroadcast } from "./lib/batch-broadcast.js";
@@ -124,48 +30,12 @@ import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions
124
30
  import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
125
31
  import { scheduleKeyRefresh } from "./lib/key-refresh.js";
126
32
  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
33
 
163
34
  export default class ActivityPubEndpoint {
164
35
  name = "ActivityPub endpoint";
165
36
 
166
37
  constructor(options = {}) {
167
- this.options = { ...defaults, ...options };
168
- this.options.actor = { ...defaults.actor, ...options.actor };
38
+ this.options = resolveOptions(options);
169
39
  this.mountPath = this.options.mountPath;
170
40
 
171
41
  this._publicationUrl = "";
@@ -179,48 +49,7 @@ export default class ActivityPubEndpoint {
179
49
  }
180
50
 
181
51
  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
- ];
52
+ return buildNavigationItems(this.options.mountPath);
224
53
  }
225
54
 
226
55
  /**
@@ -244,159 +73,14 @@ export default class ActivityPubEndpoint {
244
73
  * Fedify handles actor, inbox, outbox, followers, following.
245
74
  */
246
75
  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;
76
+ return buildRoutesPublic(this);
307
77
  }
308
78
 
309
79
  /**
310
80
  * Authenticated admin routes — mounted at mountPath, behind IndieAuth.
311
81
  */
312
82
  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;
83
+ return buildAdminRoutes(this);
400
84
  }
401
85
 
402
86
  /**
@@ -405,95 +89,7 @@ export default class ActivityPubEndpoint {
405
89
  * at /nodeinfo/2.1 (delegated to Fedify).
406
90
  */
407
91
  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;
92
+ return buildContentNegotiationRoutes(this);
497
93
  }
498
94
 
499
95
  /**
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Default plugin options for @rmdes/indiekit-endpoint-activitypub.
3
+ * Merged over user options in the endpoint constructor.
4
+ */
5
+ export const DEFAULTS = {
6
+ mountPath: "/activitypub",
7
+ actor: {
8
+ handle: "rick",
9
+ name: "",
10
+ summary: "",
11
+ icon: "",
12
+ },
13
+ checked: true,
14
+ alsoKnownAs: "",
15
+ activityRetentionDays: 90,
16
+ storeRawActivities: false,
17
+ redisUrl: "",
18
+ parallelWorkers: 5,
19
+ actorType: "Person",
20
+ logLevel: "warning",
21
+ timelineRetention: 1000,
22
+ notificationRetentionDays: 30,
23
+ debugDashboard: false,
24
+ debugPassword: "",
25
+ defaultVisibility: "public", // "public" | "unlisted" | "followers"
26
+ };
27
+
28
+ /**
29
+ * Merge user options over defaults (deep-merges the nested `actor` object).
30
+ * @param {object} [options]
31
+ * @returns {object} resolved options
32
+ */
33
+ export function resolveOptions(options = {}) {
34
+ const merged = { ...DEFAULTS, ...options };
35
+ merged.actor = { ...DEFAULTS.actor, ...options.actor };
36
+ return merged;
37
+ }
@@ -43,6 +43,10 @@ import { getSettings } from "./settings.js";
43
43
  /** @type {string} ActivityStreams Public Collection constant */
44
44
  const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
45
45
 
46
+ // Pure addressing/visibility helpers are exported for unit testing (see
47
+ // tests/inbox-visibility.test.js). They are not part of the handler API.
48
+ export { isDirectMessage as _isDirectMessage, computeVisibility as _computeVisibility };
49
+
46
50
  // ---------------------------------------------------------------------------
47
51
  // Router
48
52
  // ---------------------------------------------------------------------------
@@ -7,7 +7,9 @@
7
7
  */
8
8
 
9
9
  import { stripQuoteReferenceHtml } from "./og-unfurl.js";
10
- import { replaceCustomEmoji } from "./emoji-utils.js";
10
+ // Use the hardened replaceCustomEmoji (validates http(s) URL schemes + escapes
11
+ // attributes) — the render pipeline processes attacker-controlled remote emoji.
12
+ import { replaceCustomEmoji } from "./timeline-store.js";
11
13
  import { shortenDisplayUrls, collapseHashtagStuffing } from "./content-utils.js";
12
14
 
13
15
  /**
@@ -68,6 +68,15 @@ function parseScopes(value) {
68
68
  .filter(Boolean);
69
69
  }
70
70
 
71
+ // Pure helpers exported for unit testing (see tests/oauth-helpers.test.js).
72
+ // Not part of the router API.
73
+ export {
74
+ escapeHtml as _escapeHtml,
75
+ hashSecret as _hashSecret,
76
+ parseRedirectUris as _parseRedirectUris,
77
+ parseScopes as _parseScopes,
78
+ };
79
+
71
80
  // ─── POST /api/v1/apps — Register client application ────────────────────────
72
81
 
73
82
  router.post("/api/v1/apps", async (req, res, next) => {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Admin navigation items for the ActivityPub endpoint.
3
+ * Extracted from index.js so the nav structure is unit-testable.
4
+ */
5
+
6
+ /**
7
+ * Build the plugin's admin navigation items.
8
+ * @param {string} mountPath - The plugin mount path (e.g. "/activitypub")
9
+ * @returns {Array<{href: string, text: string, requiresDatabase: boolean}>}
10
+ */
11
+ export function buildNavigationItems(mountPath) {
12
+ return [
13
+ { href: mountPath, text: "activitypub.title", requiresDatabase: true },
14
+ { href: `${mountPath}/admin/reader`, text: "activitypub.reader.title", requiresDatabase: true },
15
+ { href: `${mountPath}/admin/reader/notifications`, text: "activitypub.notifications.title", requiresDatabase: true },
16
+ { href: `${mountPath}/admin/reader/messages`, text: "activitypub.messages.title", requiresDatabase: true },
17
+ { href: `${mountPath}/admin/reader/moderation`, text: "activitypub.moderation.title", requiresDatabase: true },
18
+ { href: `${mountPath}/admin/my-profile`, text: "activitypub.myProfile.title", requiresDatabase: true },
19
+ { href: `${mountPath}/admin/federation`, text: "activitypub.federationMgmt.title", requiresDatabase: true },
20
+ { href: `${mountPath}/admin/settings`, text: "activitypub.settings.title", requiresDatabase: true },
21
+ ];
22
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Authenticated admin UI routes for the ActivityPub endpoint.
3
+ * Extracted from index.js's `get routes()` getter (Phase 2 god-entry split).
4
+ * `self` is the ActivityPubEndpoint instance (passed through to controllers).
5
+ */
6
+ import express from "express";
7
+
8
+ import { dashboardController } from "../controllers/dashboard.js";
9
+ import {
10
+ readerController,
11
+ notificationsController,
12
+ markAllNotificationsReadController,
13
+ clearAllNotificationsController,
14
+ deleteNotificationController,
15
+ composeController,
16
+ submitComposeController,
17
+ remoteProfileController,
18
+ followController,
19
+ unfollowController,
20
+ postDetailController,
21
+ } from "../controllers/reader.js";
22
+ import {
23
+ likeController,
24
+ unlikeController,
25
+ boostController,
26
+ unboostController,
27
+ } from "../controllers/interactions.js";
28
+ import {
29
+ muteController,
30
+ unmuteController,
31
+ blockController,
32
+ unblockController,
33
+ blockServerController,
34
+ unblockServerController,
35
+ moderationController,
36
+ filterModeController,
37
+ } from "../controllers/moderation.js";
38
+ import { followersController } from "../controllers/followers.js";
39
+ import {
40
+ approveFollowController,
41
+ rejectFollowController,
42
+ } from "../controllers/follow-requests.js";
43
+ import { followingController } from "../controllers/following.js";
44
+ import { activitiesController } from "../controllers/activities.js";
45
+ import {
46
+ migrateGetController,
47
+ migratePostController,
48
+ migrateImportController,
49
+ } from "../controllers/migrate.js";
50
+ import {
51
+ profileGetController,
52
+ profilePostController,
53
+ } from "../controllers/profile.js";
54
+ import {
55
+ featuredGetController,
56
+ featuredPinController,
57
+ featuredUnpinController,
58
+ } from "../controllers/featured.js";
59
+ import {
60
+ featuredTagsGetController,
61
+ featuredTagsAddController,
62
+ featuredTagsRemoveController,
63
+ } from "../controllers/featured-tags.js";
64
+ import { resolveController } from "../controllers/resolve.js";
65
+ import { tagTimelineController } from "../controllers/tag-timeline.js";
66
+ import { apiTimelineController, countNewController, markReadController } from "../controllers/api-timeline.js";
67
+ import {
68
+ exploreController,
69
+ exploreApiController,
70
+ instanceSearchApiController,
71
+ instanceCheckApiController,
72
+ popularAccountsApiController,
73
+ } from "../controllers/explore.js";
74
+ import {
75
+ followTagController,
76
+ unfollowTagController,
77
+ followTagGloballyController,
78
+ unfollowTagGloballyController,
79
+ } from "../controllers/follow-tag.js";
80
+ import {
81
+ listTabsController,
82
+ addTabController,
83
+ removeTabController,
84
+ reorderTabsController,
85
+ } from "../controllers/tabs.js";
86
+ import { hashtagExploreApiController } from "../controllers/hashtag-explore.js";
87
+ import {
88
+ messagesController,
89
+ messageComposeController,
90
+ submitMessageController,
91
+ markAllMessagesReadController,
92
+ clearAllMessagesController,
93
+ deleteMessageController,
94
+ } from "../controllers/messages.js";
95
+ import { myProfileController } from "../controllers/my-profile.js";
96
+ import {
97
+ refollowPauseController,
98
+ refollowResumeController,
99
+ refollowStatusController,
100
+ } from "../controllers/refollow.js";
101
+ import { deleteFederationController } from "../controllers/federation-delete.js";
102
+ import {
103
+ federationMgmtController,
104
+ rebroadcastController,
105
+ viewApJsonController,
106
+ broadcastActorUpdateController,
107
+ lookupObjectController,
108
+ } from "../controllers/federation-mgmt.js";
109
+ import {
110
+ settingsGetController,
111
+ settingsPostController,
112
+ } from "../controllers/settings.js";
113
+
114
+ /**
115
+ * Build the authenticated admin router.
116
+ * @param {object} self - the ActivityPubEndpoint instance
117
+ * @returns {import("express").Router}
118
+ */
119
+ export function buildAdminRoutes(self) {
120
+ const router = express.Router(); // eslint-disable-line new-cap
121
+ const mp = self.options.mountPath;
122
+
123
+ router.get("/", dashboardController(mp));
124
+ router.get("/admin/reader", readerController(mp));
125
+ router.get("/admin/reader/tag", tagTimelineController(mp));
126
+ router.get("/admin/reader/api/timeline", apiTimelineController(mp));
127
+ router.get("/admin/reader/api/timeline/count-new", countNewController());
128
+ router.post("/admin/reader/api/timeline/mark-read", markReadController());
129
+ router.get("/admin/reader/explore", exploreController(mp));
130
+ router.get("/admin/reader/api/explore", exploreApiController(mp));
131
+ router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp));
132
+ router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
133
+ router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
134
+ router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
135
+ router.get("/admin/reader/api/tabs", listTabsController(mp));
136
+ router.post("/admin/reader/api/tabs", addTabController(mp));
137
+ router.post("/admin/reader/api/tabs/remove", removeTabController(mp));
138
+ router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
139
+ router.post("/admin/reader/follow-tag", followTagController(mp));
140
+ router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
141
+ router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, self));
142
+ router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, self));
143
+ router.get("/admin/reader/notifications", notificationsController(mp));
144
+ router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
145
+ router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
146
+ router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
147
+ router.get("/admin/reader/messages", messagesController(mp));
148
+ router.get("/admin/reader/messages/compose", messageComposeController(mp, self));
149
+ router.post("/admin/reader/messages/compose", submitMessageController(mp, self));
150
+ router.post("/admin/reader/messages/mark-read", markAllMessagesReadController(mp));
151
+ router.post("/admin/reader/messages/clear", clearAllMessagesController(mp));
152
+ router.post("/admin/reader/messages/delete", deleteMessageController(mp));
153
+ router.get("/admin/reader/compose", composeController(mp, self));
154
+ router.post("/admin/reader/compose", submitComposeController(mp, self));
155
+ router.post("/admin/reader/like", likeController(mp, self));
156
+ router.post("/admin/reader/unlike", unlikeController(mp, self));
157
+ router.post("/admin/reader/boost", boostController(mp, self));
158
+ router.post("/admin/reader/unboost", unboostController(mp, self));
159
+ router.get("/admin/reader/resolve", resolveController(mp, self));
160
+ router.get("/admin/reader/profile", remoteProfileController(mp, self));
161
+ router.get("/admin/reader/post", postDetailController(mp, self));
162
+ router.post("/admin/reader/follow", followController(mp, self));
163
+ router.post("/admin/reader/unfollow", unfollowController(mp, self));
164
+ router.get("/admin/reader/moderation", moderationController(mp));
165
+ router.post("/admin/reader/moderation/filter-mode", filterModeController(mp));
166
+ router.post("/admin/reader/mute", muteController(mp, self));
167
+ router.post("/admin/reader/unmute", unmuteController(mp, self));
168
+ router.post("/admin/reader/block", blockController(mp, self));
169
+ router.post("/admin/reader/unblock", unblockController(mp, self));
170
+ router.post("/admin/reader/block-server", blockServerController(mp));
171
+ router.post("/admin/reader/unblock-server", unblockServerController(mp));
172
+ router.get("/admin/followers", followersController(mp));
173
+ router.post("/admin/followers/approve", approveFollowController(mp, self));
174
+ router.post("/admin/followers/reject", rejectFollowController(mp, self));
175
+ router.get("/admin/following", followingController(mp));
176
+ router.get("/admin/activities", activitiesController(mp));
177
+ router.get("/admin/featured", featuredGetController(mp));
178
+ router.post("/admin/featured/pin", featuredPinController(mp, self));
179
+ router.post("/admin/featured/unpin", featuredUnpinController(mp, self));
180
+ router.get("/admin/tags", featuredTagsGetController(mp));
181
+ router.post("/admin/tags/add", featuredTagsAddController(mp, self));
182
+ router.post("/admin/tags/remove", featuredTagsRemoveController(mp, self));
183
+ router.get("/admin/profile", profileGetController(mp));
184
+ router.post("/admin/profile", profilePostController(mp, self));
185
+ router.get("/admin/my-profile", myProfileController(self));
186
+ router.get("/admin/migrate", migrateGetController(mp, self.options));
187
+ router.post("/admin/migrate", migratePostController(mp, self.options));
188
+ router.post("/admin/migrate/import", migrateImportController(mp, self.options));
189
+ router.post("/admin/refollow/pause", refollowPauseController(mp, self));
190
+ router.post("/admin/refollow/resume", refollowResumeController(mp, self));
191
+ router.get("/admin/refollow/status", refollowStatusController(mp));
192
+ router.post("/admin/federation/delete", deleteFederationController(mp, self));
193
+ router.get("/admin/federation", federationMgmtController(mp, self));
194
+ router.post("/admin/federation/rebroadcast", rebroadcastController(mp, self));
195
+ router.get("/admin/federation/ap-json", viewApJsonController(mp, self));
196
+ router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, self));
197
+ router.get("/admin/federation/lookup", lookupObjectController(mp, self));
198
+
199
+ // Settings
200
+ router.get("/admin/settings", settingsGetController(mp));
201
+ router.post("/admin/settings", settingsPostController(mp));
202
+
203
+ return router;
204
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Public (federation-facing) route getters, extracted from index.js's
3
+ * `get routesPublic()` + `get contentNegotiationRoutes()` (Phase 2 god-entry split).
4
+ * `self` is the ActivityPubEndpoint instance.
5
+ */
6
+ import express from "express";
7
+
8
+ import { authorizeInteractionController } from "../controllers/authorize-interaction.js";
9
+ import { publicProfileController } from "../controllers/public-profile.js";
10
+ import { jf2ToActivityStreams } from "../jf2-to-as2.js";
11
+
12
+ /**
13
+ * Public routes — Fedify bridge for actor/inbox/collections, plus HTML
14
+ * fallbacks. Mounted at mountPath, in front of the authenticated admin routes.
15
+ * @param {object} self - the ActivityPubEndpoint instance
16
+ * @returns {import("express").Router}
17
+ */
18
+ export function buildRoutesPublic(self) {
19
+ const router = express.Router(); // eslint-disable-line new-cap
20
+
21
+ router.use((req, res, next) => {
22
+ if (!self._fedifyMiddleware) return next();
23
+ // Skip Fedify for admin UI routes — they're handled by the
24
+ // authenticated `routes` getter, not the federation layer.
25
+ if (req.path.startsWith("/admin")) return next();
26
+
27
+ // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
28
+ // (it only returns true for explicit application/activity+json etc.).
29
+ // Remote servers fetching actor URLs for HTTP Signature verification
30
+ // (e.g. tags.pub) often omit Accept or use */* — they get HTML back
31
+ // instead of the actor JSON, causing "public key not found" errors.
32
+ // Fix: for GET requests to actor paths, upgrade ambiguous Accept headers
33
+ // to application/activity+json so Fedify serves JSON-LD. Explicit
34
+ // text/html requests (browsers) are unaffected.
35
+ if (req.method === "GET" && /^\/users\/[^/]+\/?$/.test(req.path)) {
36
+ const accept = req.get("accept") || "";
37
+ if (!accept.includes("text/html") && !accept.includes("application/xhtml+xml")) {
38
+ req.headers["accept"] = "application/activity+json";
39
+ }
40
+ }
41
+
42
+ return self._fedifyMiddleware(req, res, next);
43
+ });
44
+
45
+ // Authorize interaction — remote follow / subscribe endpoint.
46
+ // Remote servers redirect users here via the WebFinger subscribe template.
47
+ router.get("/authorize_interaction", authorizeInteractionController(self));
48
+
49
+ // HTML fallback for actor URL — serve a public profile page.
50
+ // Fedify only serves JSON-LD; browsers get 406 and fall through here.
51
+ router.get("/users/:identifier", publicProfileController(self));
52
+
53
+ // Catch-all for federation paths that Fedify didn't handle (e.g. GET
54
+ // on inbox). Without this, they fall through to Indiekit's auth
55
+ // middleware and redirect to the login page.
56
+ router.all("/users/:identifier/inbox", (req, res) => {
57
+ res
58
+ .status(405)
59
+ .set("Allow", "POST")
60
+ .type("application/activity+json")
61
+ .json({
62
+ error: "Method Not Allowed",
63
+ message: "The inbox only accepts POST requests",
64
+ });
65
+ });
66
+ router.all("/inbox", (req, res) => {
67
+ res
68
+ .status(405)
69
+ .set("Allow", "POST")
70
+ .type("application/activity+json")
71
+ .json({
72
+ error: "Method Not Allowed",
73
+ message: "The shared inbox only accepts POST requests",
74
+ });
75
+ });
76
+
77
+ return router;
78
+ }
79
+
80
+ /**
81
+ * Content negotiation — serves AS2 JSON for ActivityPub clients requesting
82
+ * individual post URLs; delegates /nodeinfo/2.1 to Fedify.
83
+ * @param {object} self - the ActivityPubEndpoint instance
84
+ * @returns {import("express").Router}
85
+ */
86
+ export function buildContentNegotiationRoutes(self) {
87
+ const router = express.Router(); // eslint-disable-line new-cap
88
+
89
+ // Let Fedify handle NodeInfo data (/nodeinfo/2.1)
90
+ // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
91
+ // Fedify here, because fromExpressRequest() consumes the body stream,
92
+ // breaking Express body-parsed routes downstream (e.g. admin forms).
93
+ router.use((req, res, next) => {
94
+ if (!self._fedifyMiddleware) return next();
95
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
96
+ // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1).
97
+ // All other paths in this root-mounted router are handled by the
98
+ // content negotiation catch-all below. Passing arbitrary paths like
99
+ // /notes/... to Fedify causes harmless but noisy 404 warnings.
100
+ if (!req.path.startsWith("/nodeinfo/")) return next();
101
+ return self._fedifyMiddleware(req, res, next);
102
+ });
103
+
104
+ // Content negotiation for AP clients on regular URLs
105
+ router.get("{*path}", async (req, res, next) => {
106
+ const accept = req.headers.accept || "";
107
+ const isActivityPub =
108
+ accept.includes("application/activity+json") ||
109
+ accept.includes("application/ld+json");
110
+
111
+ if (!isActivityPub) {
112
+ return next();
113
+ }
114
+
115
+ try {
116
+ // Root URL — redirect to Fedify actor
117
+ if (req.path === "/") {
118
+ const actorPath = `${self.options.mountPath}/users/${self.options.actor.handle}`;
119
+ return res.redirect(actorPath);
120
+ }
121
+
122
+ // Post URLs — look up in database and convert to AS2
123
+ const { application } = req.app.locals;
124
+ const postsCollection = application?.collections?.get("posts");
125
+ if (!postsCollection) {
126
+ return next();
127
+ }
128
+
129
+ const requestUrl = `${self._publicationUrl}${req.path.slice(1)}`;
130
+ const post = await postsCollection.findOne({
131
+ "properties.url": requestUrl,
132
+ });
133
+
134
+ if (!post || post.properties?.deleted) {
135
+ // FEP-4f05: Serve Tombstone for deleted posts
136
+ const { getTombstone } = await import("../storage/tombstones.js");
137
+ const tombstone = await getTombstone(self._collections, requestUrl);
138
+ if (tombstone) {
139
+ res.status(410).set("Content-Type", "application/activity+json").json({
140
+ "@context": "https://www.w3.org/ns/activitystreams",
141
+ type: "Tombstone",
142
+ id: requestUrl,
143
+ formerType: tombstone.formerType,
144
+ published: tombstone.published || undefined,
145
+ deleted: tombstone.deleted,
146
+ });
147
+ return;
148
+ }
149
+ return next();
150
+ }
151
+
152
+ const actorUrl = self._getActorUrl();
153
+ const activity = jf2ToActivityStreams(
154
+ post.properties,
155
+ actorUrl,
156
+ self._publicationUrl,
157
+ { visibility: self.options.defaultVisibility },
158
+ );
159
+
160
+ const object = activity.object || activity;
161
+ res.set("Content-Type", "application/activity+json");
162
+ return res.json({
163
+ "@context": [
164
+ "https://www.w3.org/ns/activitystreams",
165
+ "https://w3id.org/security/v1",
166
+ ],
167
+ ...object,
168
+ });
169
+ } catch {
170
+ return next();
171
+ }
172
+ });
173
+
174
+ return router;
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.13.9",
3
+ "version": "3.13.11",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -1,38 +0,0 @@
1
- /**
2
- * Custom emoji replacement for fediverse content.
3
- *
4
- * Replaces :shortcode: patterns with <img> tags for custom emoji.
5
- * Must be called AFTER sanitizeContent() — the inserted <img> tags
6
- * would be stripped if run through the sanitizer.
7
- */
8
-
9
- /**
10
- * Escape special regex characters in a string.
11
- * @param {string} str
12
- * @returns {string}
13
- */
14
- function escapeRegex(str) {
15
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
- }
17
-
18
- /**
19
- * Replace :shortcode: patterns in HTML with custom emoji <img> tags.
20
- *
21
- * @param {string} html - HTML string (already sanitized)
22
- * @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji list
23
- * @returns {string} HTML with emoji shortcodes replaced by img tags
24
- */
25
- export function replaceCustomEmoji(html, emojis) {
26
- if (!html || !emojis?.length) return html;
27
-
28
- for (const emoji of emojis) {
29
- if (!emoji.shortcode || !emoji.url) continue;
30
- const pattern = new RegExp(`:${escapeRegex(emoji.shortcode)}:`, "g");
31
- html = html.replace(
32
- pattern,
33
- `<img src="${emoji.url}" alt=":${emoji.shortcode}:" title=":${emoji.shortcode}:" class="ap-custom-emoji" loading="lazy">`,
34
- );
35
- }
36
-
37
- return html;
38
- }