@rmdes/indiekit-endpoint-activitypub 2.0.24 → 2.0.26

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
@@ -60,6 +60,7 @@ import {
60
60
  } from "./lib/controllers/featured-tags.js";
61
61
  import { resolveController } from "./lib/controllers/resolve.js";
62
62
  import { publicProfileController } from "./lib/controllers/public-profile.js";
63
+ import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
63
64
  import { myProfileController } from "./lib/controllers/my-profile.js";
64
65
  import { noteObjectController } from "./lib/controllers/note-object.js";
65
66
  import {
@@ -173,6 +174,10 @@ export default class ActivityPubEndpoint {
173
174
  // dereference the Note ID during Create activity verification.
174
175
  router.get("/quick-replies/:id", noteObjectController(self));
175
176
 
177
+ // Authorize interaction — remote follow / subscribe endpoint.
178
+ // Remote servers redirect users here via the WebFinger subscribe template.
179
+ router.get("/authorize_interaction", authorizeInteractionController(self));
180
+
176
181
  // HTML fallback for actor URL — serve a public profile page.
177
182
  // Fedify only serves JSON-LD; browsers get 406 and fall through here.
178
183
  router.get("/users/:identifier", publicProfileController(self));
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Authorize Interaction controller — handles the remote follow / authorize
3
+ * interaction flow for ActivityPub federation.
4
+ *
5
+ * When a remote server (WordPress AP, Misskey, etc.) discovers our WebFinger
6
+ * subscribe template, it redirects the user here with ?uri={actorOrPostUrl}.
7
+ *
8
+ * Flow:
9
+ * 1. Missing uri → render error page
10
+ * 2. Unauthenticated → redirect to login, then back here
11
+ * 3. Authenticated → redirect to the reader's remote profile page
12
+ */
13
+
14
+ export function authorizeInteractionController(plugin) {
15
+ return async (req, res) => {
16
+ const uri = req.query.uri || req.query.acct;
17
+ if (!uri) {
18
+ return res.status(400).render("activitypub-authorize-interaction", {
19
+ title: "Authorize Interaction",
20
+ mountPath: plugin.options.mountPath,
21
+ error: "Missing uri parameter",
22
+ });
23
+ }
24
+
25
+ // Clean up acct: prefix if present
26
+ const resource = uri.replace(/^acct:/, "");
27
+
28
+ // Check authentication — if not logged in, redirect to login
29
+ // then back to this page after auth
30
+ const session = req.session;
31
+ if (!session?.access_token) {
32
+ const returnUrl = `${plugin.options.mountPath}/authorize_interaction?uri=${encodeURIComponent(uri)}`;
33
+ return res.redirect(
34
+ `/session/login?redirect=${encodeURIComponent(returnUrl)}`,
35
+ );
36
+ }
37
+
38
+ // Authenticated — redirect to the remote profile viewer in our reader
39
+ // which already has follow/unfollow/like/boost functionality
40
+ const encodedUrl = encodeURIComponent(resource);
41
+ return res.redirect(
42
+ `${plugin.options.mountPath}/admin/reader/profile?url=${encodedUrl}`,
43
+ );
44
+ };
45
+ }
@@ -72,11 +72,11 @@ async function sendFedifyResponse(res, response, request) {
72
72
  return;
73
73
  }
74
74
 
75
- // WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints"
76
- // which is not a real ActivityStreams type (fails browser.pub validation).
77
- // For actor JSON responses, buffer the body and strip the invalid type.
78
- // See: https://github.com/fedify-dev/fedify/issues/576
79
- // TODO: Remove this workaround when Fedify fixes the upstream issue.
75
+ // WORKAROUND: JSON-LD compaction collapses single-element arrays to a
76
+ // plain object. Mastodon's update_account_fields checks
77
+ // `attachment.is_a?(Array)` and skips if it's not an array, so
78
+ // profile links/PropertyValues are silently ignored.
79
+ // Force `attachment` to always be an array for Mastodon compatibility.
80
80
  const contentType = response.headers.get("content-type") || "";
81
81
  const isActorJson =
82
82
  contentType.includes("activity+json") ||
@@ -86,14 +86,6 @@ async function sendFedifyResponse(res, response, request) {
86
86
  const body = await response.text();
87
87
  try {
88
88
  const json = JSON.parse(body);
89
- if (json.endpoints?.type) {
90
- delete json.endpoints.type;
91
- }
92
- // WORKAROUND: Fedify's JSON-LD compaction collapses single-element
93
- // arrays to a plain object. Mastodon's update_account_fields checks
94
- // `attachment.is_a?(Array)` and skips if it's not an array, so
95
- // profile links/PropertyValues are silently ignored.
96
- // Force `attachment` to always be an array for Mastodon compatibility.
97
89
  if (json.attachment && !Array.isArray(json.attachment)) {
98
90
  json.attachment = [json.attachment];
99
91
  }
@@ -262,6 +262,18 @@ export function setupFederation(options) {
262
262
  // instance actor's keys for outgoing fetches), which Fedify doesn't yet
263
263
  // support out of the box. Re-enable once Fedify adds this capability.
264
264
 
265
+ // --- WebFinger custom links ---
266
+ // Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.)
267
+ // can redirect users to our authorize_interaction page for remote follow.
268
+ federation.setWebFingerLinksDispatcher((_ctx, _resource) => {
269
+ return [
270
+ {
271
+ rel: "http://ostatus.org/schema/1.0/subscribe",
272
+ template: `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction?uri={uri}`,
273
+ },
274
+ ];
275
+ });
276
+
265
277
  // --- Inbox listeners ---
266
278
  const inboxChain = federation.setInboxListeners(
267
279
  `${mountPath}/users/{identifier}/inbox`,
@@ -41,9 +41,20 @@ import { fetchAndStorePreviews } from "./og-unfurl.js";
41
41
  export function registerInboxListeners(inboxChain, options) {
42
42
  const { collections, handle, storeRawActivities } = options;
43
43
 
44
+ /**
45
+ * Get an authenticated DocumentLoader that signs outbound fetches with
46
+ * our actor's key. This allows .getActor()/.getObject() to succeed
47
+ * against Authorized Fetch (Secure Mode) servers like hachyderm.io.
48
+ *
49
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
50
+ * @returns {Promise<import("@fedify/fedify").DocumentLoader>}
51
+ */
52
+ const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
53
+
44
54
  inboxChain
45
55
  .on(Follow, async (ctx, follow) => {
46
- const followerActor = await follow.getActor();
56
+ const authLoader = await getAuthLoader(ctx);
57
+ const followerActor = await follow.getActor({ documentLoader: authLoader });
47
58
  if (!followerActor?.id) return;
48
59
 
49
60
  const followerUrl = followerActor.id.href;
@@ -90,7 +101,7 @@ export function registerInboxListeners(inboxChain, options) {
90
101
  });
91
102
 
92
103
  // Store notification
93
- const followerInfo = await extractActorInfo(followerActor);
104
+ const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
94
105
  await addNotification(collections, {
95
106
  uid: follow.id?.href || `follow:${followerUrl}`,
96
107
  type: "follow",
@@ -104,9 +115,10 @@ export function registerInboxListeners(inboxChain, options) {
104
115
  })
105
116
  .on(Undo, async (ctx, undo) => {
106
117
  const actorUrl = undo.actorId?.href || "";
118
+ const authLoader = await getAuthLoader(ctx);
107
119
  let inner;
108
120
  try {
109
- inner = await undo.getObject();
121
+ inner = await undo.getObject({ documentLoader: authLoader });
110
122
  } catch {
111
123
  // Inner activity not dereferenceable — can't determine what was undone
112
124
  return;
@@ -150,7 +162,8 @@ export function registerInboxListeners(inboxChain, options) {
150
162
  // it to a Person (the Follow's target) rather than the Follow itself.
151
163
  // Instead, we match directly against ap_following — if we have a
152
164
  // pending follow for this actor, any Accept from them confirms it.
153
- const actorObj = await accept.getActor();
165
+ const authLoader = await getAuthLoader(ctx);
166
+ const actorObj = await accept.getActor({ documentLoader: authLoader });
154
167
  const actorUrl = actorObj?.id?.href || "";
155
168
  if (!actorUrl) return;
156
169
 
@@ -186,7 +199,8 @@ export function registerInboxListeners(inboxChain, options) {
186
199
  }
187
200
  })
188
201
  .on(Reject, async (ctx, reject) => {
189
- const actorObj = await reject.getActor();
202
+ const authLoader = await getAuthLoader(ctx);
203
+ const actorObj = await reject.getActor({ documentLoader: authLoader });
190
204
  const actorUrl = actorObj?.id?.href || "";
191
205
  if (!actorUrl) return;
192
206
 
@@ -217,20 +231,19 @@ export function registerInboxListeners(inboxChain, options) {
217
231
  }
218
232
  })
219
233
  .on(Like, async (ctx, like) => {
220
- // Use .objectId to get the URL without dereferencing the remote object.
221
- // Calling .getObject() would trigger an HTTP fetch to the remote server,
222
- // which fails with 404 when the server has Authorized Fetch (Secure Mode)
223
- // enabled — causing pointless retries and log spam.
234
+ // Use .objectId (non-fetching) for the liked URL we only need the
235
+ // URL to filter and log, not the full remote object.
224
236
  const objectId = like.objectId?.href || "";
225
237
 
226
238
  // Only log likes of our own content
227
239
  const pubUrl = collections._publicationUrl;
228
240
  if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
229
241
 
242
+ const authLoader = await getAuthLoader(ctx);
230
243
  const actorUrl = like.actorId?.href || "";
231
244
  let actorObj;
232
245
  try {
233
- actorObj = await like.getActor();
246
+ actorObj = await like.getActor({ documentLoader: authLoader });
234
247
  } catch {
235
248
  actorObj = null;
236
249
  }
@@ -250,7 +263,7 @@ export function registerInboxListeners(inboxChain, options) {
250
263
  });
251
264
 
252
265
  // Store notification
253
- const actorInfo = await extractActorInfo(actorObj);
266
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
254
267
  await addNotification(collections, {
255
268
  uid: like.id?.href || `like:${actorUrl}:${objectId}`,
256
269
  type: "like",
@@ -268,6 +281,7 @@ export function registerInboxListeners(inboxChain, options) {
268
281
  const objectId = announce.objectId?.href || "";
269
282
  if (!objectId) return;
270
283
 
284
+ const authLoader = await getAuthLoader(ctx);
271
285
  const actorUrl = announce.actorId?.href || "";
272
286
  const pubUrl = collections._publicationUrl;
273
287
 
@@ -277,7 +291,7 @@ export function registerInboxListeners(inboxChain, options) {
277
291
  if (pubUrl && objectId.startsWith(pubUrl)) {
278
292
  let actorObj;
279
293
  try {
280
- actorObj = await announce.getActor();
294
+ actorObj = await announce.getActor({ documentLoader: authLoader });
281
295
  } catch {
282
296
  actorObj = null;
283
297
  }
@@ -298,7 +312,7 @@ export function registerInboxListeners(inboxChain, options) {
298
312
  });
299
313
 
300
314
  // Create notification
301
- const actorInfo = await extractActorInfo(actorObj);
315
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
302
316
  await addNotification(collections, {
303
317
  uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
304
318
  type: "boost",
@@ -319,8 +333,8 @@ export function registerInboxListeners(inboxChain, options) {
319
333
  const following = await collections.ap_following.findOne({ actorUrl });
320
334
  if (following) {
321
335
  try {
322
- // Fetch the original object being boosted
323
- const object = await announce.getObject();
336
+ // Fetch the original object being boosted (authenticated for Secure Mode servers)
337
+ const object = await announce.getObject({ documentLoader: authLoader });
324
338
  if (!object) return;
325
339
 
326
340
  // Skip non-content objects (Lemmy/PieFed like/create activities
@@ -329,13 +343,14 @@ export function registerInboxListeners(inboxChain, options) {
329
343
  if (!hasContent) return;
330
344
 
331
345
  // Get booster actor info
332
- const boosterActor = await announce.getActor();
333
- const boosterInfo = await extractActorInfo(boosterActor);
346
+ const boosterActor = await announce.getActor({ documentLoader: authLoader });
347
+ const boosterInfo = await extractActorInfo(boosterActor, { documentLoader: authLoader });
334
348
 
335
349
  // Extract and store with boost metadata
336
350
  const timelineItem = await extractObjectData(object, {
337
351
  boostedBy: boosterInfo,
338
352
  boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
353
+ documentLoader: authLoader,
339
354
  });
340
355
 
341
356
  await addTimelineItem(collections, timelineItem);
@@ -347,11 +362,12 @@ export function registerInboxListeners(inboxChain, options) {
347
362
  }
348
363
  })
349
364
  .on(Create, async (ctx, create) => {
365
+ const authLoader = await getAuthLoader(ctx);
350
366
  let object;
351
367
  try {
352
- object = await create.getObject();
368
+ object = await create.getObject({ documentLoader: authLoader });
353
369
  } catch {
354
- // Remote object not dereferenceable (Authorized Fetch, deleted, etc.)
370
+ // Remote object not dereferenceable (deleted, etc.)
355
371
  return;
356
372
  }
357
373
  if (!object) return;
@@ -359,7 +375,7 @@ export function registerInboxListeners(inboxChain, options) {
359
375
  const actorUrl = create.actorId?.href || "";
360
376
  let actorObj;
361
377
  try {
362
- actorObj = await create.getActor();
378
+ actorObj = await create.getActor({ documentLoader: authLoader });
363
379
  } catch {
364
380
  // Actor not dereferenceable — use URL as fallback
365
381
  actorObj = null;
@@ -389,7 +405,7 @@ export function registerInboxListeners(inboxChain, options) {
389
405
 
390
406
  // Create notification if reply is to one of OUR posts
391
407
  if (pubUrl && inReplyTo.startsWith(pubUrl)) {
392
- const actorInfo = await extractActorInfo(actorObj);
408
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
393
409
  const rawHtml = object.content?.toString() || "";
394
410
  const contentHtml = sanitizeContent(rawHtml);
395
411
  const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
@@ -420,7 +436,7 @@ export function registerInboxListeners(inboxChain, options) {
420
436
 
421
437
  for (const tag of tags) {
422
438
  if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
423
- const actorInfo = await extractActorInfo(actorObj);
439
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
424
440
  const rawMentionHtml = object.content?.toString() || "";
425
441
  const mentionHtml = sanitizeContent(rawMentionHtml);
426
442
  const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
@@ -451,6 +467,7 @@ export function registerInboxListeners(inboxChain, options) {
451
467
  try {
452
468
  const timelineItem = await extractObjectData(object, {
453
469
  actorFallback: actorObj,
470
+ documentLoader: authLoader,
454
471
  });
455
472
  await addTimelineItem(collections, timelineItem);
456
473
 
@@ -479,9 +496,10 @@ export function registerInboxListeners(inboxChain, options) {
479
496
  }
480
497
  })
481
498
  .on(Move, async (ctx, move) => {
482
- const oldActorObj = await move.getActor();
499
+ const authLoader = await getAuthLoader(ctx);
500
+ const oldActorObj = await move.getActor({ documentLoader: authLoader });
483
501
  const oldActorUrl = oldActorObj?.id?.href || "";
484
- const target = await move.getTarget();
502
+ const target = await move.getTarget({ documentLoader: authLoader });
485
503
  const newActorUrl = target?.id?.href || "";
486
504
 
487
505
  if (oldActorUrl && newActorUrl) {
@@ -501,11 +519,12 @@ export function registerInboxListeners(inboxChain, options) {
501
519
  })
502
520
  .on(Update, async (ctx, update) => {
503
521
  // Update can be for a profile OR for a post (edited content)
522
+ const authLoader = await getAuthLoader(ctx);
504
523
 
505
524
  // Try to get the object being updated
506
525
  let object;
507
526
  try {
508
- object = await update.getObject();
527
+ object = await update.getObject({ documentLoader: authLoader });
509
528
  } catch {
510
529
  object = null;
511
530
  }
@@ -538,7 +557,7 @@ export function registerInboxListeners(inboxChain, options) {
538
557
  }
539
558
 
540
559
  // PATH 2: Otherwise, assume profile update — refresh stored follower data
541
- const actorObj = await update.getActor();
560
+ const actorObj = await update.getActor({ documentLoader: authLoader });
542
561
  const actorUrl = actorObj?.id?.href || "";
543
562
  if (!actorUrl) return;
544
563
 
@@ -564,7 +583,8 @@ export function registerInboxListeners(inboxChain, options) {
564
583
  })
565
584
  .on(Block, async (ctx, block) => {
566
585
  // Remote actor blocked us — remove them from followers
567
- const actorObj = await block.getActor();
586
+ const authLoader = await getAuthLoader(ctx);
587
+ const actorObj = await block.getActor({ documentLoader: authLoader });
568
588
  const actorUrl = actorObj?.id?.href || "";
569
589
  if (actorUrl) {
570
590
  await collections.ap_followers.deleteOne({ actorUrl });
@@ -36,9 +36,11 @@ export function sanitizeContent(html) {
36
36
  /**
37
37
  * Extract actor information from Fedify Person/Application/Service object
38
38
  * @param {object} actor - Fedify actor object
39
+ * @param {object} [options] - Options
40
+ * @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
39
41
  * @returns {object} { name, url, photo, handle }
40
42
  */
41
- export async function extractActorInfo(actor) {
43
+ export async function extractActorInfo(actor, options = {}) {
42
44
  if (!actor) {
43
45
  return {
44
46
  name: "Unknown",
@@ -54,10 +56,11 @@ export async function extractActorInfo(actor) {
54
56
  const url = actor.id?.href || "";
55
57
 
56
58
  // Extract photo URL from icon (Fedify uses async getters)
59
+ const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
57
60
  let photo = "";
58
61
  try {
59
62
  if (typeof actor.getIcon === "function") {
60
- const iconObj = await actor.getIcon();
63
+ const iconObj = await actor.getIcon(loaderOpts);
61
64
  photo = iconObj?.url?.href || "";
62
65
  } else {
63
66
  const iconObj = await actor.icon;
@@ -89,6 +92,7 @@ export async function extractActorInfo(actor) {
89
92
  * @param {object} [options.boostedBy] - Actor info for boosts
90
93
  * @param {Date} [options.boostedAt] - Boost timestamp
91
94
  * @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
95
+ * @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
92
96
  * @returns {Promise<object>} Timeline item data
93
97
  */
94
98
  export async function extractObjectData(object, options = {}) {
@@ -130,14 +134,15 @@ export async function extractObjectData(object, options = {}) {
130
134
  : new Date().toISOString();
131
135
 
132
136
  // Extract author — try multiple strategies in order of reliability
137
+ const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
133
138
  let authorObj = null;
134
139
  try {
135
140
  if (typeof object.getAttributedTo === "function") {
136
- const attr = await object.getAttributedTo();
141
+ const attr = await object.getAttributedTo(loaderOpts);
137
142
  authorObj = Array.isArray(attr) ? attr[0] : attr;
138
143
  }
139
144
  } catch {
140
- // getAttributedTo() failed (Authorized Fetch, unreachable, etc.)
145
+ // getAttributedTo() failed (unreachable, deleted, etc.)
141
146
  }
142
147
  // If getAttributedTo() returned nothing, use the actor from the wrapping activity
143
148
  if (!authorObj && options.actorFallback) {
@@ -150,7 +155,7 @@ export async function extractObjectData(object, options = {}) {
150
155
 
151
156
  let author;
152
157
  if (authorObj) {
153
- author = await extractActorInfo(authorObj);
158
+ author = await extractActorInfo(authorObj, loaderOpts);
154
159
  } else {
155
160
  // Last resort: use attributionIds (non-fetching) to get at least a URL
156
161
  const attrIds = object.attributionIds;
@@ -184,7 +189,7 @@ export async function extractObjectData(object, options = {}) {
184
189
  const category = [];
185
190
  try {
186
191
  if (typeof object.getTags === "function") {
187
- const tags = await object.getTags();
192
+ const tags = await object.getTags(loaderOpts);
188
193
  for await (const tag of tags) {
189
194
  if (tag.name) {
190
195
  const tagName = tag.name.toString().replace(/^#/, "");
@@ -203,7 +208,7 @@ export async function extractObjectData(object, options = {}) {
203
208
 
204
209
  try {
205
210
  if (typeof object.getAttachments === "function") {
206
- const attachments = await object.getAttachments();
211
+ const attachments = await object.getAttachments(loaderOpts);
207
212
  for await (const att of attachments) {
208
213
  const mediaUrl = att.url?.href || "";
209
214
  if (!mediaUrl) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.24",
3
+ "version": "2.0.26",
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",
@@ -0,0 +1,10 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "prose/macro.njk" import prose with context %}
4
+
5
+ {% block content %}
6
+ {% if error %}
7
+ {{ prose({ text: error }) }}
8
+ {% endif %}
9
+ <p><a href="{{ mountPath }}/">Return to dashboard</a></p>
10
+ {% endblock %}