@rmdes/indiekit-endpoint-activitypub 2.0.7 → 2.0.9

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
@@ -59,6 +59,7 @@ import {
59
59
  } from "./lib/controllers/featured-tags.js";
60
60
  import { resolveController } from "./lib/controllers/resolve.js";
61
61
  import { publicProfileController } from "./lib/controllers/public-profile.js";
62
+ import { noteObjectController } from "./lib/controllers/note-object.js";
62
63
  import {
63
64
  refollowPauseController,
64
65
  refollowResumeController,
@@ -161,6 +162,10 @@ export default class ActivityPubEndpoint {
161
162
  return self._fedifyMiddleware(req, res, next);
162
163
  });
163
164
 
165
+ // Serve stored quick reply Notes as JSON-LD so remote servers can
166
+ // dereference the Note ID during Create activity verification.
167
+ router.get("/quick-replies/:id", noteObjectController(self));
168
+
164
169
  // HTML fallback for actor URL — serve a public profile page.
165
170
  // Fedify only serves JSON-LD; browsers get 406 and fall through here.
166
171
  router.get("/users/:identifier", publicProfileController(self));
@@ -835,6 +840,7 @@ export default class ActivityPubEndpoint {
835
840
  Indiekit.addCollection("ap_muted");
836
841
  Indiekit.addCollection("ap_blocked");
837
842
  Indiekit.addCollection("ap_interactions");
843
+ Indiekit.addCollection("ap_notes");
838
844
 
839
845
  // Store collection references (posts resolved lazily)
840
846
  const indiekitCollections = Indiekit.collections;
@@ -853,6 +859,7 @@ export default class ActivityPubEndpoint {
853
859
  ap_muted: indiekitCollections.get("ap_muted"),
854
860
  ap_blocked: indiekitCollections.get("ap_blocked"),
855
861
  ap_interactions: indiekitCollections.get("ap_interactions"),
862
+ ap_notes: indiekitCollections.get("ap_notes"),
856
863
  get posts() {
857
864
  return indiekitCollections.get("posts");
858
865
  },
@@ -5,6 +5,7 @@
5
5
  import { Temporal } from "@js-temporal/polyfill";
6
6
  import { getToken, validateToken } from "../csrf.js";
7
7
  import { sanitizeContent } from "../timeline-store.js";
8
+ import { resolveAuthor } from "../resolve-author.js";
8
9
 
9
10
  /**
10
11
  * Fetch syndication targets from the Micropub config endpoint.
@@ -205,33 +206,20 @@ export function submitComposeController(mountPath, plugin) {
205
206
  );
206
207
  const followersUri = ctx.getFollowersUri(handle);
207
208
 
209
+ const documentLoader = await ctx.getDocumentLoader({
210
+ identifier: handle,
211
+ });
212
+
208
213
  // Resolve the original author BEFORE constructing the Note,
209
214
  // so we can include them in cc (required for threading/notification)
210
215
  let recipient = null;
211
216
  if (inReplyTo) {
212
- try {
213
- const documentLoader = await ctx.getDocumentLoader({
214
- identifier: handle,
215
- });
216
- const remoteObject = await ctx.lookupObject(new URL(inReplyTo), {
217
- documentLoader,
218
- });
219
-
220
- if (
221
- remoteObject &&
222
- typeof remoteObject.getAttributedTo === "function"
223
- ) {
224
- const author = await remoteObject.getAttributedTo({
225
- documentLoader,
226
- });
227
- recipient = Array.isArray(author) ? author[0] : author;
228
- }
229
- } catch (error) {
230
- console.warn(
231
- `[ActivityPub] lookupObject failed for ${inReplyTo} (quick reply):`,
232
- error.message,
233
- );
234
- }
217
+ recipient = await resolveAuthor(
218
+ inReplyTo,
219
+ ctx,
220
+ documentLoader,
221
+ application?.collections,
222
+ );
235
223
  }
236
224
 
237
225
  // Build cc list: always include followers, add original author for replies
@@ -258,6 +246,21 @@ export function submitComposeController(mountPath, plugin) {
258
246
  ccs: ccList,
259
247
  });
260
248
 
249
+ // Store the Note so remote servers can dereference its ID
250
+ const ap_notes = application?.collections?.get("ap_notes");
251
+ if (ap_notes) {
252
+ await ap_notes.insertOne({
253
+ _id: uuid,
254
+ noteId,
255
+ actorUrl: actorUri.href,
256
+ content: content.trim(),
257
+ inReplyTo: inReplyTo || null,
258
+ published: new Date().toISOString(),
259
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
260
+ cc: ccList.map((u) => (u instanceof URL ? u.href : u.href || u)),
261
+ });
262
+ }
263
+
261
264
  // Send to followers
262
265
  await ctx.sendActivity({ identifier: handle }, "followers", create, {
263
266
  preferSharedInbox: true,
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { validateToken } from "../csrf.js";
7
+ import { resolveAuthor } from "../resolve-author.js";
7
8
 
8
9
  /**
9
10
  * POST /admin/reader/boost — send an Announce activity to followers.
@@ -66,40 +67,38 @@ export function boostController(mountPath, plugin) {
66
67
  orderingKey: url,
67
68
  });
68
69
 
69
- // Also send to the original post author (signed request for Authorized Fetch)
70
- try {
71
- const documentLoader = await ctx.getDocumentLoader({
72
- identifier: handle,
73
- });
74
- const remoteObject = await ctx.lookupObject(new URL(url), {
75
- documentLoader,
76
- });
70
+ // Also send directly to the original post author
71
+ const documentLoader = await ctx.getDocumentLoader({
72
+ identifier: handle,
73
+ });
74
+ const { application } = request.app.locals;
75
+ const recipient = await resolveAuthor(
76
+ url,
77
+ ctx,
78
+ documentLoader,
79
+ application?.collections,
80
+ );
77
81
 
78
- if (
79
- remoteObject &&
80
- typeof remoteObject.getAttributedTo === "function"
81
- ) {
82
- const author = await remoteObject.getAttributedTo({ documentLoader });
83
- const recipient = Array.isArray(author) ? author[0] : author;
84
-
85
- if (recipient) {
86
- await ctx.sendActivity(
87
- { identifier: handle },
88
- recipient,
89
- announce,
90
- { orderingKey: url },
91
- );
92
- }
82
+ if (recipient) {
83
+ try {
84
+ await ctx.sendActivity(
85
+ { identifier: handle },
86
+ recipient,
87
+ announce,
88
+ { orderingKey: url },
89
+ );
90
+ console.info(
91
+ `[ActivityPub] Sent boost directly to ${recipient.id?.href || "author"}`,
92
+ );
93
+ } catch (error) {
94
+ console.warn(
95
+ `[ActivityPub] Direct boost delivery to author failed:`,
96
+ error.message,
97
+ );
93
98
  }
94
- } catch (error) {
95
- console.warn(
96
- `[ActivityPub] lookupObject failed for ${url} (boost):`,
97
- error.message,
98
- );
99
99
  }
100
100
 
101
101
  // Track the interaction
102
- const { application } = request.app.locals;
103
102
  const interactions = application?.collections?.get("ap_interactions");
104
103
 
105
104
  if (interactions) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { validateToken } from "../csrf.js";
7
+ import { resolveAuthor } from "../resolve-author.js";
7
8
 
8
9
  /**
9
10
  * POST /admin/reader/like — send a Like activity to the post author.
@@ -43,57 +44,23 @@ export function likeController(mountPath, plugin) {
43
44
  { handle, publicationUrl: plugin._publicationUrl },
44
45
  );
45
46
 
46
- // Use authenticated document loader for servers requiring Authorized Fetch
47
47
  const documentLoader = await ctx.getDocumentLoader({
48
48
  identifier: handle,
49
49
  });
50
50
 
51
- // Resolve author for delivery — try multiple strategies
52
- let recipient = null;
53
-
54
- // Strategy 1: Look up remote post via Fedify (signed request)
55
- try {
56
- const remoteObject = await ctx.lookupObject(new URL(url), {
57
- documentLoader,
58
- });
59
- if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
60
- const author = await remoteObject.getAttributedTo({ documentLoader });
61
- recipient = Array.isArray(author) ? author[0] : author;
62
- }
63
- } catch (error) {
64
- console.warn(
65
- `[ActivityPub] lookupObject failed for ${url}:`,
66
- error.message,
67
- );
68
- }
51
+ const { application } = request.app.locals;
52
+ const recipient = await resolveAuthor(
53
+ url,
54
+ ctx,
55
+ documentLoader,
56
+ application?.collections,
57
+ );
69
58
 
70
- // Strategy 2: Use author URL from our timeline (already stored)
71
- // Note: Timeline items store both uid (canonical AP URL) and url (display URL).
72
- // The card passes the display URL, so we search by both fields.
73
59
  if (!recipient) {
74
- const { application } = request.app.locals;
75
- const ap_timeline = application?.collections?.get("ap_timeline");
76
- const timelineItem = ap_timeline
77
- ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
78
- : null;
79
- const authorUrl = timelineItem?.author?.url;
80
-
81
- if (authorUrl) {
82
- try {
83
- recipient = await ctx.lookupObject(new URL(authorUrl), {
84
- documentLoader,
85
- });
86
- } catch {
87
- // Could not resolve author actor either
88
- }
89
- }
90
-
91
- if (!recipient) {
92
- return response.status(404).json({
93
- success: false,
94
- error: "Could not resolve post author",
95
- });
96
- }
60
+ return response.status(404).json({
61
+ success: false,
62
+ error: "Could not resolve post author",
63
+ });
97
64
  }
98
65
 
99
66
  // Generate a unique activity ID
@@ -113,7 +80,6 @@ export function likeController(mountPath, plugin) {
113
80
  });
114
81
 
115
82
  // Track the interaction for undo
116
- const { application } = request.app.locals;
117
83
  const interactions = application?.collections?.get("ap_interactions");
118
84
 
119
85
  if (interactions) {
@@ -200,46 +166,16 @@ export function unlikeController(mountPath, plugin) {
200
166
  { handle, publicationUrl: plugin._publicationUrl },
201
167
  );
202
168
 
203
- // Use authenticated document loader for servers requiring Authorized Fetch
204
169
  const documentLoader = await ctx.getDocumentLoader({
205
170
  identifier: handle,
206
171
  });
207
172
 
208
- // Resolve the recipient try remote first, then timeline fallback
209
- let recipient = null;
210
-
211
- try {
212
- const remoteObject = await ctx.lookupObject(new URL(url), {
213
- documentLoader,
214
- });
215
- if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
216
- const author = await remoteObject.getAttributedTo({ documentLoader });
217
- recipient = Array.isArray(author) ? author[0] : author;
218
- }
219
- } catch (error) {
220
- console.warn(
221
- `[ActivityPub] lookupObject failed for ${url} (unlike):`,
222
- error.message,
223
- );
224
- }
225
-
226
- if (!recipient) {
227
- const ap_timeline = application?.collections?.get("ap_timeline");
228
- const timelineItem = ap_timeline
229
- ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
230
- : null;
231
- const authorUrl = timelineItem?.author?.url;
232
-
233
- if (authorUrl) {
234
- try {
235
- recipient = await ctx.lookupObject(new URL(authorUrl), {
236
- documentLoader,
237
- });
238
- } catch {
239
- // Could not resolve — will proceed to cleanup
240
- }
241
- }
242
- }
173
+ const recipient = await resolveAuthor(
174
+ url,
175
+ ctx,
176
+ documentLoader,
177
+ application?.collections,
178
+ );
243
179
 
244
180
  if (!recipient) {
245
181
  // Clean up the local record even if we can't send Undo
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Public route handler for serving quick reply Notes as ActivityPub JSON-LD.
3
+ *
4
+ * Remote servers dereference Note IDs to verify Create activities.
5
+ * Without this, quick replies are rejected by servers that validate
6
+ * the Note's ID URL (Mastodon with Authorized Fetch, Bonfire, etc.).
7
+ */
8
+
9
+ /**
10
+ * GET /quick-replies/:id — serve a stored Note as JSON-LD.
11
+ * @param {object} plugin - ActivityPub plugin instance
12
+ */
13
+ export function noteObjectController(plugin) {
14
+ return async (request, response) => {
15
+ const { id } = request.params;
16
+
17
+ const { application } = request.app.locals;
18
+ const ap_notes = application?.collections?.get("ap_notes");
19
+
20
+ if (!ap_notes) {
21
+ return response.status(404).json({ error: "Not Found" });
22
+ }
23
+
24
+ const note = await ap_notes.findOne({ _id: id });
25
+
26
+ if (!note) {
27
+ return response.status(404).json({ error: "Not Found" });
28
+ }
29
+
30
+ const noteJson = {
31
+ "@context": "https://www.w3.org/ns/activitystreams",
32
+ id: note.noteId,
33
+ type: "Note",
34
+ attributedTo: note.actorUrl,
35
+ content: note.content,
36
+ published: note.published,
37
+ to: note.to,
38
+ cc: note.cc,
39
+ };
40
+
41
+ if (note.inReplyTo) {
42
+ noteJson.inReplyTo = note.inReplyTo;
43
+ }
44
+
45
+ response
46
+ .status(200)
47
+ .set("Content-Type", "application/activity+json; charset=utf-8")
48
+ .set("Cache-Control", "public, max-age=3600")
49
+ .json(noteJson);
50
+ };
51
+ }
@@ -340,7 +340,9 @@ export function registerInboxListeners(inboxChain, options) {
340
340
 
341
341
  await addTimelineItem(collections, timelineItem);
342
342
  } catch (error) {
343
- console.error("Failed to store boosted timeline item:", error);
343
+ // Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
344
+ const cause = error?.cause?.code || error?.message || "unknown";
345
+ console.warn(`[AP] Skipped boost from ${actorUrl}: ${cause}`);
344
346
  }
345
347
  }
346
348
  })
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Multi-strategy author resolution for interaction delivery.
3
+ *
4
+ * Resolves a post URL to the author's Actor object so that Like, Announce,
5
+ * and other activities can be delivered to the correct inbox.
6
+ *
7
+ * Strategies (tried in order):
8
+ * 1. lookupObject on post URL → getAttributedTo
9
+ * 2. Timeline/notification DB lookup → lookupObject on stored author URL
10
+ * 3. Extract author URL from post URL pattern → lookupObject
11
+ */
12
+
13
+ /**
14
+ * Extract a probable author URL from a post URL using common fediverse patterns.
15
+ *
16
+ * @param {string} postUrl - The post URL
17
+ * @returns {string|null} - Author URL or null
18
+ *
19
+ * Patterns matched:
20
+ * https://instance/users/USERNAME/statuses/ID → https://instance/users/USERNAME
21
+ * https://instance/@USERNAME/ID → https://instance/users/USERNAME
22
+ * https://instance/p/USERNAME/ID → https://instance/users/USERNAME (Pixelfed)
23
+ * https://instance/notice/ID → null (no username in URL)
24
+ */
25
+ export function extractAuthorUrl(postUrl) {
26
+ try {
27
+ const parsed = new URL(postUrl);
28
+ const path = parsed.pathname;
29
+
30
+ // /users/USERNAME/statuses/ID — Mastodon, GoToSocial, Akkoma canonical
31
+ const usersMatch = path.match(/^\/users\/([^/]+)\//);
32
+ if (usersMatch) {
33
+ return `${parsed.origin}/users/${usersMatch[1]}`;
34
+ }
35
+
36
+ // /@USERNAME/ID — Mastodon display URL
37
+ const atMatch = path.match(/^\/@([^/]+)\/\d/);
38
+ if (atMatch) {
39
+ return `${parsed.origin}/users/${atMatch[1]}`;
40
+ }
41
+
42
+ // /p/USERNAME/ID — Pixelfed
43
+ const pixelfedMatch = path.match(/^\/p\/([^/]+)\/\d/);
44
+ if (pixelfedMatch) {
45
+ return `${parsed.origin}/users/${pixelfedMatch[1]}`;
46
+ }
47
+
48
+ return null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve the author Actor for a given post URL.
56
+ *
57
+ * @param {string} postUrl - The post URL to resolve the author for
58
+ * @param {object} ctx - Fedify context
59
+ * @param {object} documentLoader - Authenticated document loader
60
+ * @param {object} [collections] - Optional MongoDB collections map (application.collections)
61
+ * @returns {Promise<object|null>} - Fedify Actor object or null
62
+ */
63
+ export async function resolveAuthor(
64
+ postUrl,
65
+ ctx,
66
+ documentLoader,
67
+ collections,
68
+ ) {
69
+ // Strategy 1: Look up remote post via Fedify (signed request)
70
+ try {
71
+ const remoteObject = await ctx.lookupObject(new URL(postUrl), {
72
+ documentLoader,
73
+ });
74
+ if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
75
+ const author = await remoteObject.getAttributedTo({ documentLoader });
76
+ const recipient = Array.isArray(author) ? author[0] : author;
77
+ if (recipient) {
78
+ console.info(
79
+ `[ActivityPub] Resolved author via lookupObject for ${postUrl}`,
80
+ );
81
+ return recipient;
82
+ }
83
+ }
84
+ } catch (error) {
85
+ console.warn(
86
+ `[ActivityPub] lookupObject failed for ${postUrl}:`,
87
+ error.message,
88
+ );
89
+ }
90
+
91
+ // Strategy 2: Use author URL from timeline or notifications
92
+ if (collections) {
93
+ const ap_timeline = collections.get("ap_timeline");
94
+ const ap_notifications = collections.get("ap_notifications");
95
+
96
+ // Search timeline by both uid (canonical) and url (display)
97
+ let authorUrl = null;
98
+ if (ap_timeline) {
99
+ const item = await ap_timeline.findOne({
100
+ $or: [{ uid: postUrl }, { url: postUrl }],
101
+ });
102
+ authorUrl = item?.author?.url;
103
+ }
104
+
105
+ // Fall back to notifications if not in timeline
106
+ if (!authorUrl && ap_notifications) {
107
+ const notif = await ap_notifications.findOne({
108
+ $or: [{ objectUrl: postUrl }, { targetUrl: postUrl }],
109
+ });
110
+ authorUrl = notif?.actorUrl;
111
+ }
112
+
113
+ if (authorUrl) {
114
+ try {
115
+ const actor = await ctx.lookupObject(new URL(authorUrl), {
116
+ documentLoader,
117
+ });
118
+ if (actor) {
119
+ console.info(
120
+ `[ActivityPub] Resolved author via DB for ${postUrl} → ${authorUrl}`,
121
+ );
122
+ return actor;
123
+ }
124
+ } catch (error) {
125
+ console.warn(
126
+ `[ActivityPub] lookupObject failed for author ${authorUrl}:`,
127
+ error.message,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ // Strategy 3: Extract author URL from post URL pattern
134
+ const extractedUrl = extractAuthorUrl(postUrl);
135
+ if (extractedUrl) {
136
+ try {
137
+ const actor = await ctx.lookupObject(new URL(extractedUrl), {
138
+ documentLoader,
139
+ });
140
+ if (actor) {
141
+ console.info(
142
+ `[ActivityPub] Resolved author via URL pattern for ${postUrl} → ${extractedUrl}`,
143
+ );
144
+ return actor;
145
+ }
146
+ } catch (error) {
147
+ console.warn(
148
+ `[ActivityPub] lookupObject failed for extracted author ${extractedUrl}:`,
149
+ error.message,
150
+ );
151
+ }
152
+ }
153
+
154
+ console.warn(`[ActivityPub] All author resolution strategies failed for ${postUrl}`);
155
+ return null;
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
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",