@rmdes/indiekit-endpoint-activitypub 2.0.7 → 2.0.8

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.
@@ -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,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.8",
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",