@rmdes/indiekit-endpoint-activitypub 2.0.6 → 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.
|
@@ -204,6 +204,42 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
204
204
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
205
205
|
);
|
|
206
206
|
const followersUri = ctx.getFollowersUri(handle);
|
|
207
|
+
|
|
208
|
+
// Resolve the original author BEFORE constructing the Note,
|
|
209
|
+
// so we can include them in cc (required for threading/notification)
|
|
210
|
+
let recipient = null;
|
|
211
|
+
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
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Build cc list: always include followers, add original author for replies
|
|
238
|
+
const ccList = [followersUri];
|
|
239
|
+
if (recipient?.id) {
|
|
240
|
+
ccList.push(recipient.id);
|
|
241
|
+
}
|
|
242
|
+
|
|
207
243
|
const note = new Note({
|
|
208
244
|
id: new URL(noteId),
|
|
209
245
|
attribution: actorUri,
|
|
@@ -211,7 +247,7 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
211
247
|
replyTarget: inReplyTo ? new URL(inReplyTo) : undefined,
|
|
212
248
|
published: Temporal.Now.instant(),
|
|
213
249
|
to: publicAddress,
|
|
214
|
-
|
|
250
|
+
ccs: ccList,
|
|
215
251
|
});
|
|
216
252
|
|
|
217
253
|
const create = new Create({
|
|
@@ -219,7 +255,7 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
219
255
|
actor: actorUri,
|
|
220
256
|
object: note,
|
|
221
257
|
to: publicAddress,
|
|
222
|
-
|
|
258
|
+
ccs: ccList,
|
|
223
259
|
});
|
|
224
260
|
|
|
225
261
|
// Send to followers
|
|
@@ -229,38 +265,23 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
229
265
|
orderingKey: noteId,
|
|
230
266
|
});
|
|
231
267
|
|
|
232
|
-
//
|
|
233
|
-
if (
|
|
268
|
+
// Also send directly to the original author's inbox
|
|
269
|
+
if (recipient) {
|
|
234
270
|
try {
|
|
235
|
-
|
|
236
|
-
identifier: handle,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const recipient = Array.isArray(author)
|
|
250
|
-
? author[0]
|
|
251
|
-
: author;
|
|
252
|
-
|
|
253
|
-
if (recipient) {
|
|
254
|
-
await ctx.sendActivity(
|
|
255
|
-
{ identifier: handle },
|
|
256
|
-
recipient,
|
|
257
|
-
create,
|
|
258
|
-
{ orderingKey: noteId },
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
} catch {
|
|
263
|
-
// Non-critical — followers still got it
|
|
271
|
+
await ctx.sendActivity(
|
|
272
|
+
{ identifier: handle },
|
|
273
|
+
recipient,
|
|
274
|
+
create,
|
|
275
|
+
{ orderingKey: noteId },
|
|
276
|
+
);
|
|
277
|
+
console.info(
|
|
278
|
+
`[ActivityPub] Sent quick reply directly to ${recipient.id?.href || "author"}`,
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.warn(
|
|
282
|
+
`[ActivityPub] Direct delivery to author failed (quick reply):`,
|
|
283
|
+
error.message,
|
|
284
|
+
);
|
|
264
285
|
}
|
|
265
286
|
}
|
|
266
287
|
|
|
@@ -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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
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",
|