@rmdes/indiekit-endpoint-activitypub 3.8.4 → 3.8.6

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
@@ -387,38 +387,6 @@ export default class ActivityPubEndpoint {
387
387
  const router = express.Router(); // eslint-disable-line new-cap
388
388
  const self = this;
389
389
 
390
- // Intercept Micropub delete actions to broadcast Delete to fediverse.
391
- // Wraps res.json to detect successful delete responses, then fires
392
- // broadcastDelete asynchronously so remote servers remove the post.
393
- router.use((req, res, next) => {
394
- if (req.method !== "POST") return next();
395
- if (!req.path.endsWith("/micropub")) return next();
396
-
397
- const action = req.query?.action || req.body?.action;
398
- if (action !== "delete") return next();
399
-
400
- const postUrl = req.query?.url || req.body?.url;
401
- if (!postUrl) return next();
402
-
403
- const originalJson = res.json.bind(res);
404
- res.json = function (body) {
405
- // Fire broadcastDelete after successful delete (status 200)
406
- if (res.statusCode === 200 && body?.success === "delete") {
407
- console.info(
408
- `[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
409
- );
410
- self.broadcastDelete(postUrl).catch((error) => {
411
- console.warn(
412
- `[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
413
- );
414
- });
415
- }
416
- return originalJson(body);
417
- };
418
-
419
- return next();
420
- });
421
-
422
390
  // Let Fedify handle NodeInfo data (/nodeinfo/2.1)
423
391
  // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
424
392
  // Fedify here, because fromExpressRequest() consumes the body stream,
@@ -715,6 +683,9 @@ export default class ActivityPubEndpoint {
715
683
  return undefined;
716
684
  }
717
685
  },
686
+
687
+ delete: async (url) => this.delete(url),
688
+ update: async (properties) => this.update(properties),
718
689
  };
719
690
  }
720
691
 
@@ -744,7 +715,7 @@ export default class ActivityPubEndpoint {
744
715
  "pkcs8",
745
716
  Buffer.from(pemBody, "base64"),
746
717
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
747
- false,
718
+ true,
748
719
  ["sign"],
749
720
  );
750
721
  } catch (error) {
@@ -1181,6 +1152,138 @@ export default class ActivityPubEndpoint {
1181
1152
  }
1182
1153
  }
1183
1154
 
1155
+ /**
1156
+ * Called by post-content.js when a Micropub delete succeeds.
1157
+ * Broadcasts an ActivityPub Delete activity to all followers.
1158
+ * @param {string} url - Full URL of the deleted post
1159
+ */
1160
+ async delete(url) {
1161
+ await this.broadcastDelete(url).catch((err) =>
1162
+ console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
1163
+ );
1164
+ }
1165
+
1166
+ /**
1167
+ * Called by post-content.js when a Micropub update succeeds.
1168
+ * Broadcasts an ActivityPub Update activity for the post to all followers.
1169
+ * @param {object} properties - JF2 post properties (must include url)
1170
+ */
1171
+ async update(properties) {
1172
+ await this.broadcastPostUpdate(properties).catch((err) =>
1173
+ console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
1174
+ );
1175
+ }
1176
+
1177
+ /**
1178
+ * Send an Update activity to all followers for a modified post.
1179
+ * Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
1180
+ * @param {object} properties - JF2 post properties
1181
+ */
1182
+ async broadcastPostUpdate(properties) {
1183
+ if (!this._federation) return;
1184
+
1185
+ try {
1186
+ const { Update } = await import("@fedify/fedify/vocab");
1187
+ const actorUrl = this._getActorUrl();
1188
+ const handle = this.options.actor.handle;
1189
+ const ctx = this._federation.createContext(
1190
+ new URL(this._publicationUrl),
1191
+ { handle, publicationUrl: this._publicationUrl },
1192
+ );
1193
+
1194
+ // Build the Note/Article object by calling jf2ToAS2Activity() and
1195
+ // extracting the wrapped object from the returned Create activity.
1196
+ // For post edits, Fediverse servers expect an Update activity wrapping
1197
+ // the updated object — NOT a second Create activity.
1198
+ const createActivity = jf2ToAS2Activity(
1199
+ properties,
1200
+ actorUrl,
1201
+ this._publicationUrl,
1202
+ { visibility: this.options.defaultVisibility },
1203
+ );
1204
+
1205
+ if (!createActivity) {
1206
+ console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
1207
+ return;
1208
+ }
1209
+
1210
+ // Extract the Note/Article object from the Create wrapper, then build
1211
+ // an Update activity around it — matching broadcastActorUpdate() pattern.
1212
+ const noteObject = await createActivity.getObject();
1213
+ const activity = new Update({
1214
+ actor: ctx.getActorUri(handle),
1215
+ object: noteObject,
1216
+ });
1217
+
1218
+ const followers = await this._collections.ap_followers
1219
+ .find({})
1220
+ .project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
1221
+ .toArray();
1222
+
1223
+ const inboxMap = new Map();
1224
+ for (const f of followers) {
1225
+ const key = f.sharedInbox || f.inbox;
1226
+ if (key && !inboxMap.has(key)) {
1227
+ inboxMap.set(key, f);
1228
+ }
1229
+ }
1230
+
1231
+ const uniqueRecipients = [...inboxMap.values()];
1232
+ const BATCH_SIZE = 25;
1233
+ const BATCH_DELAY_MS = 5000;
1234
+ let delivered = 0;
1235
+ let failed = 0;
1236
+
1237
+ console.info(
1238
+ `[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`,
1239
+ );
1240
+
1241
+ for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
1242
+ const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
1243
+ const recipients = batch.map((f) => ({
1244
+ id: new URL(f.actorUrl),
1245
+ inboxId: new URL(f.inbox || f.sharedInbox),
1246
+ endpoints: f.sharedInbox
1247
+ ? { sharedInbox: new URL(f.sharedInbox) }
1248
+ : undefined,
1249
+ }));
1250
+
1251
+ try {
1252
+ await ctx.sendActivity(
1253
+ { identifier: handle },
1254
+ recipients,
1255
+ activity,
1256
+ { preferSharedInbox: true },
1257
+ );
1258
+ delivered += batch.length;
1259
+ } catch (error) {
1260
+ failed += batch.length;
1261
+ console.warn(
1262
+ `[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
1263
+ );
1264
+ }
1265
+
1266
+ if (i + BATCH_SIZE < uniqueRecipients.length) {
1267
+ await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
1268
+ }
1269
+ }
1270
+
1271
+ console.info(
1272
+ `[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`,
1273
+ );
1274
+
1275
+ await logActivity(this._collections.ap_activities, {
1276
+ direction: "outbound",
1277
+ type: "Update",
1278
+ actorUrl: this._publicationUrl,
1279
+ objectUrl: properties.url,
1280
+ summary: `Sent Update for ${properties.url} to ${delivered} inboxes`,
1281
+ }).catch(() => {});
1282
+ } catch (error) {
1283
+ console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
1284
+ }
1285
+ }
1286
+
1184
1287
  /**
1185
1288
  * Build the full actor URL from config.
1186
1289
  * @returns {string}
@@ -1589,6 +1692,7 @@ export default class ActivityPubEndpoint {
1589
1692
  federation: this._federation,
1590
1693
  followActor: (url, info) => pluginRef.followActor(url, info),
1591
1694
  unfollowActor: (url) => pluginRef.unfollowActor(url),
1695
+ loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
1592
1696
  },
1593
1697
  });
1594
1698
  Indiekit.addEndpoint({
@@ -1614,6 +1718,7 @@ export default class ActivityPubEndpoint {
1614
1718
  }, 10_000);
1615
1719
 
1616
1720
  // Run one-time migrations (idempotent — safe to run on every startup)
1721
+ console.info("[ActivityPub] Init: starting post-refollow setup");
1617
1722
  runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => {
1618
1723
  if (!skipped) {
1619
1724
  console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`);
@@ -1660,6 +1765,7 @@ export default class ActivityPubEndpoint {
1660
1765
  });
1661
1766
 
1662
1767
  // Start async inbox queue processor (processes one item every 3s)
1768
+ console.info("[ActivityPub] Init: starting inbox queue processor");
1663
1769
  this._inboxProcessorInterval = startInboxProcessor(
1664
1770
  this._collections,
1665
1771
  () => this._federation?.createContext(new URL(this._publicationUrl), {
@@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) {
72
72
  identifier: handle,
73
73
  });
74
74
  const { application } = request.app.locals;
75
+ const rsaKey = await plugin._loadRsaPrivateKey();
75
76
  const recipient = await resolveAuthor(
76
77
  url,
77
78
  ctx,
78
79
  documentLoader,
79
80
  application?.collections,
81
+ {
82
+ privateKey: rsaKey,
83
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
84
+ },
80
85
  );
81
86
 
82
87
  if (recipient) {
@@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) {
49
49
  });
50
50
 
51
51
  const { application } = request.app.locals;
52
+ const rsaKey = await plugin._loadRsaPrivateKey();
52
53
  const recipient = await resolveAuthor(
53
54
  url,
54
55
  ctx,
55
56
  documentLoader,
56
57
  application?.collections,
58
+ {
59
+ privateKey: rsaKey,
60
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
61
+ },
57
62
  );
58
63
 
59
64
  if (!recipient) {
@@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) {
170
175
  identifier: handle,
171
176
  });
172
177
 
178
+ const rsaKey2 = await plugin._loadRsaPrivateKey();
173
179
  const recipient = await resolveAuthor(
174
180
  url,
175
181
  ctx,
176
182
  documentLoader,
177
183
  application?.collections,
184
+ {
185
+ privateKey: rsaKey2,
186
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
187
+ },
178
188
  );
179
189
 
180
190
  if (!recipient) {
@@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js";
22
22
  * @param {object} params.interactions - ap_interactions collection
23
23
  * @returns {Promise<{ activityId: string }>}
24
24
  */
25
- export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
25
+ export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
26
26
  const { Like } = await import("@fedify/fedify/vocab");
27
27
  const ctx = federation.createContext(
28
28
  new URL(publicationUrl),
@@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
30
30
  );
31
31
 
32
32
  const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
33
- const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
33
+ const rsaKey = loadRsaKey ? await loadRsaKey() : null;
34
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
35
+ privateKey: rsaKey,
36
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
37
+ });
34
38
 
35
39
  const uuid = crypto.randomUUID();
36
40
  const baseUrl = publicationUrl.replace(/\/$/, "");
@@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
79
83
  * @param {object} params.interactions - ap_interactions collection
80
84
  * @returns {Promise<void>}
81
85
  */
82
- export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
86
+ export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
83
87
  const existing = interactions
84
88
  ? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
85
89
  : null;
@@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
95
99
  );
96
100
 
97
101
  const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
98
- const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
102
+ const rsaKey = loadRsaKey ? await loadRsaKey() : null;
103
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
104
+ privateKey: rsaKey,
105
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
106
+ });
99
107
 
100
108
  if (recipient) {
101
109
  const like = new Like({
@@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
131
139
  * @param {object} params.interactions - ap_interactions collection
132
140
  * @returns {Promise<{ activityId: string }>}
133
141
  */
134
- export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
142
+ export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
135
143
  const { Announce } = await import("@fedify/fedify/vocab");
136
144
  const ctx = federation.createContext(
137
145
  new URL(publicationUrl),
@@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
162
170
 
163
171
  // Also send directly to the original post author
164
172
  const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
165
- const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
173
+ const rsaKey = loadRsaKey ? await loadRsaKey() : null;
174
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
175
+ privateKey: rsaKey,
176
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
177
+ });
166
178
  if (recipient) {
167
179
  try {
168
180
  await ctx.sendActivity({ identifier: handle }, recipient, announce, {
@@ -614,6 +614,7 @@ function getFederationOpts(req) {
614
614
  handle: pluginOptions.handle || "user",
615
615
  publicationUrl: pluginOptions.publicationUrl,
616
616
  collections: req.app.locals.mastodonCollections,
617
+ loadRsaKey: pluginOptions.loadRsaKey,
617
618
  };
618
619
  }
619
620
 
@@ -6,6 +6,8 @@
6
6
  *
7
7
  * Strategies (tried in order):
8
8
  * 1. lookupObject on post URL → getAttributedTo
9
+ * 1b. Raw signed fetch fallback (for servers like wafrn that return
10
+ * non-standard JSON-LD that Fedify can't parse)
9
11
  * 2. Timeline/notification DB lookup → lookupObject on stored author URL
10
12
  * 3. Extract author URL from post URL pattern → lookupObject
11
13
  */
@@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) {
60
62
  * @param {object} ctx - Fedify context
61
63
  * @param {object} documentLoader - Authenticated document loader
62
64
  * @param {object} [collections] - Optional MongoDB collections map (application.collections)
65
+ * @param {object} [options] - Additional options
66
+ * @param {CryptoKey} [options.privateKey] - RSA private key for raw signed fetch fallback
67
+ * @param {string} [options.keyId] - Key ID for HTTP Signature (e.g. "...#main-key")
63
68
  * @returns {Promise<object|null>} - Fedify Actor object or null
64
69
  */
65
70
  export async function resolveAuthor(
@@ -67,6 +72,7 @@ export async function resolveAuthor(
67
72
  ctx,
68
73
  documentLoader,
69
74
  collections,
75
+ options = {},
70
76
  ) {
71
77
  // Strategy 1: Look up remote post via Fedify (signed request)
72
78
  try {
@@ -90,6 +96,46 @@ export async function resolveAuthor(
90
96
  );
91
97
  }
92
98
 
99
+ // Strategy 1b: Raw signed fetch fallback
100
+ // Some servers (e.g. wafrn) return AP JSON without @context, which Fedify's
101
+ // JSON-LD processor rejects. A raw fetch can still extract attributedTo/actor.
102
+ if (options.privateKey && options.keyId) {
103
+ try {
104
+ const { signRequest } = await import("@fedify/fedify/sig");
105
+ const request = new Request(postUrl, {
106
+ method: "GET",
107
+ headers: { Accept: "application/activity+json" },
108
+ });
109
+ const signed = await signRequest(request, options.privateKey, new URL(options.keyId), {
110
+ spec: "draft-cavage-http-signatures-12",
111
+ });
112
+ const res = await fetch(signed, { redirect: "follow" });
113
+ if (res.ok) {
114
+ const contentType = res.headers.get("content-type") || "";
115
+ if (contentType.includes("json")) {
116
+ const json = await res.json();
117
+ const authorUrl = json.attributedTo || json.actor;
118
+ if (authorUrl && typeof authorUrl === "string") {
119
+ const actor = await lookupWithSecurity(ctx, new URL(authorUrl), {
120
+ documentLoader,
121
+ });
122
+ if (actor) {
123
+ console.info(
124
+ `[ActivityPub] Resolved author via raw fetch for ${postUrl} → ${authorUrl}`,
125
+ );
126
+ return actor;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ } catch (error) {
132
+ console.warn(
133
+ `[ActivityPub] Raw fetch fallback failed for ${postUrl}:`,
134
+ error.message,
135
+ );
136
+ }
137
+ }
138
+
93
139
  // Strategy 2: Use author URL from timeline or notifications
94
140
  if (collections) {
95
141
  const ap_timeline = collections.get("ap_timeline");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.8.4",
3
+ "version": "3.8.6",
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",