@rmdes/indiekit-endpoint-activitypub 3.4.0 → 3.5.2

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
@@ -224,6 +224,14 @@ export default class ActivityPubEndpoint {
224
224
  // Skip Fedify for admin UI routes — they're handled by the
225
225
  // authenticated `routes` getter, not the federation layer.
226
226
  if (req.path.startsWith("/admin")) return next();
227
+
228
+ // Diagnostic: log inbox POSTs to detect federation stalls
229
+ if (req.method === "POST" && req.path.includes("inbox")) {
230
+ const ua = req.get("user-agent") || "unknown";
231
+ const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
232
+ console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
233
+ }
234
+
227
235
  return self._fedifyMiddleware(req, res, next);
228
236
  });
229
237
 
@@ -984,6 +984,24 @@ export async function handleUpdate(item, collections, ctx, handle) {
984
984
 
985
985
  const existing = await collections.ap_followers.findOne({ actorUrl });
986
986
  if (existing) {
987
+ let updatedAvatar = "";
988
+ try {
989
+ updatedAvatar = actorObj.icon
990
+ ? (await actorObj.icon)?.url?.href || ""
991
+ : "";
992
+ } catch {
993
+ // Icon fetch failed
994
+ }
995
+
996
+ let updatedBanner = "";
997
+ try {
998
+ updatedBanner = actorObj.image
999
+ ? (await actorObj.image)?.url?.href || ""
1000
+ : "";
1001
+ } catch {
1002
+ // Banner fetch failed
1003
+ }
1004
+
987
1005
  await collections.ap_followers.updateOne(
988
1006
  { actorUrl },
989
1007
  {
@@ -993,9 +1011,8 @@ export async function handleUpdate(item, collections, ctx, handle) {
993
1011
  actorObj.preferredUsername?.toString() ||
994
1012
  actorUrl,
995
1013
  handle: actorObj.preferredUsername?.toString() || "",
996
- avatar: actorObj.icon
997
- ? (await actorObj.icon)?.url?.href || ""
998
- : "",
1014
+ avatar: updatedAvatar,
1015
+ banner: updatedBanner,
999
1016
  updatedAt: new Date().toISOString(),
1000
1017
  },
1001
1018
  },
@@ -46,12 +46,31 @@ export function registerInboxListeners(inboxChain, options) {
46
46
 
47
47
  const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
48
48
 
49
+ // Diagnostic: track listener invocations to detect federation stalls
50
+ const _diag = { count: 0, lastType: "", lastActor: "", lastAt: 0 };
51
+ const _diagInterval = setInterval(() => {
52
+ if (_diag.count > 0) {
53
+ console.info(`[inbox-diag] ${_diag.count} activities received in last 5min (last: ${_diag.lastType} from ${_diag.lastActor})`);
54
+ _diag.count = 0;
55
+ }
56
+ }, 5 * 60 * 1000);
57
+ // Prevent timer from keeping the process alive
58
+ _diagInterval.unref?.();
59
+
60
+ const _diagTrack = (type, actorUrl) => {
61
+ _diag.count++;
62
+ _diag.lastType = type;
63
+ _diag.lastActor = actorUrl?.split("/").pop() || "?";
64
+ _diag.lastAt = Date.now();
65
+ };
66
+
49
67
  inboxChain
50
68
  // ── Follow ──────────────────────────────────────────────────────
51
69
  // Synchronous: Accept/Reject + follower storage (federation requirement)
52
70
  // Async: notification + activity log
53
71
  .on(Follow, async (ctx, follow) => {
54
72
  const actorUrl = follow.actorId?.href || "";
73
+ _diagTrack("Follow", actorUrl);
55
74
  if (await isServerBlocked(actorUrl, collections)) return;
56
75
  await touchKeyFreshness(collections, actorUrl);
57
76
  await resetDeliveryStrikes(collections, actorUrl);
@@ -66,13 +85,30 @@ export function registerInboxListeners(inboxChain, options) {
66
85
  followerActor.preferredUsername?.toString() ||
67
86
  followerUrl;
68
87
 
88
+ let followerAvatar = "";
89
+ try {
90
+ followerAvatar = followerActor.icon
91
+ ? (await followerActor.icon)?.url?.href || ""
92
+ : "";
93
+ } catch {
94
+ // Icon fetch failed
95
+ }
96
+
97
+ let followerBanner = "";
98
+ try {
99
+ followerBanner = followerActor.image
100
+ ? (await followerActor.image)?.url?.href || ""
101
+ : "";
102
+ } catch {
103
+ // Banner fetch failed
104
+ }
105
+
69
106
  const followerData = {
70
107
  actorUrl: followerUrl,
71
108
  handle: followerActor.preferredUsername?.toString() || "",
72
109
  name: followerName,
73
- avatar: followerActor.icon
74
- ? (await followerActor.icon)?.url?.href || ""
75
- : "",
110
+ avatar: followerAvatar,
111
+ banner: followerBanner,
76
112
  inbox: followerActor.inbox?.id?.href || "",
77
113
  sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
78
114
  };
@@ -209,6 +245,7 @@ export function registerInboxListeners(inboxChain, options) {
209
245
  // ── Announce ────────────────────────────────────────────────────
210
246
  .on(Announce, async (ctx, announce) => {
211
247
  const actorUrl = announce.actorId?.href || "";
248
+ _diagTrack("Announce", actorUrl);
212
249
  if (await isServerBlocked(actorUrl, collections)) return;
213
250
  await touchKeyFreshness(collections, actorUrl);
214
251
  await resetDeliveryStrikes(collections, actorUrl);
@@ -224,6 +261,7 @@ export function registerInboxListeners(inboxChain, options) {
224
261
  // ── Create ──────────────────────────────────────────────────────
225
262
  .on(Create, async (ctx, create) => {
226
263
  const actorUrl = create.actorId?.href || "";
264
+ _diagTrack("Create", actorUrl);
227
265
  if (await isServerBlocked(actorUrl, collections)) return;
228
266
  await touchKeyFreshness(collections, actorUrl);
229
267
  await resetDeliveryStrikes(collections, actorUrl);
@@ -274,6 +312,7 @@ export function registerInboxListeners(inboxChain, options) {
274
312
  // ── Delete ──────────────────────────────────────────────────────
275
313
  .on(Delete, async (ctx, del) => {
276
314
  const actorUrl = del.actorId?.href || "";
315
+ _diagTrack("Delete", actorUrl);
277
316
  if (await isServerBlocked(actorUrl, collections)) return;
278
317
  await touchKeyFreshness(collections, actorUrl);
279
318
  await resetDeliveryStrikes(collections, actorUrl);
@@ -303,6 +342,7 @@ export function registerInboxListeners(inboxChain, options) {
303
342
  // ── Update ──────────────────────────────────────────────────────
304
343
  .on(Update, async (ctx, update) => {
305
344
  const actorUrl = update.actorId?.href || "";
345
+ _diagTrack("Update", actorUrl);
306
346
  if (await isServerBlocked(actorUrl, collections)) return;
307
347
  await touchKeyFreshness(collections, actorUrl);
308
348
  await resetDeliveryStrikes(collections, actorUrl);
@@ -83,11 +83,32 @@ export async function enqueueActivity(collections, { activityType, actorUrl, obj
83
83
  * @returns {NodeJS.Timeout} Interval ID (for cleanup)
84
84
  */
85
85
  export function startInboxProcessor(collections, getCtx, handle) {
86
+ // Diagnostic: detect stuck processing items and log queue health
87
+ let _diagProcessed = 0;
88
+ const _diagInterval = setInterval(async () => {
89
+ try {
90
+ const stuck = await collections.ap_inbox_queue?.countDocuments({ status: "processing" }) || 0;
91
+ const pending = await collections.ap_inbox_queue?.countDocuments({ status: "pending" }) || 0;
92
+ if (stuck > 0 || _diagProcessed > 0 || pending > 10) {
93
+ console.info(`[inbox-queue-diag] processed=${_diagProcessed}/5min pending=${pending} stuck_processing=${stuck}`);
94
+ }
95
+ _diagProcessed = 0;
96
+ } catch { /* ignore */ }
97
+ }, 5 * 60 * 1000);
98
+ _diagInterval.unref?.();
99
+
86
100
  const intervalId = setInterval(async () => {
87
101
  try {
88
102
  const ctx = getCtx();
89
103
  if (ctx) {
104
+ const before = Date.now();
90
105
  await processNextItem(collections, ctx, handle);
106
+ const elapsed = Date.now() - before;
107
+ if (elapsed > 0) _diagProcessed++;
108
+ // Warn if a single item takes too long (potential hang)
109
+ if (elapsed > 30_000) {
110
+ console.warn(`[inbox-queue-diag] slow item: ${elapsed}ms`);
111
+ }
91
112
  }
92
113
  } catch (error) {
93
114
  console.error("[inbox-queue] Processor error:", error.message);
@@ -55,17 +55,26 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
55
55
  );
56
56
 
57
57
  // Resolve the associated status (for favourite, reblog, mention types)
58
+ // For mention types, prefer the triggering post (notif.url) over the target post (notif.targetUrl)
59
+ // because targetUrl for replies points to the user's OWN post being replied to
58
60
  let status = null;
59
- if (notif.targetUrl && statusMap) {
60
- const timelineItem = statusMap.get(notif.targetUrl);
61
- if (timelineItem) {
62
- status = serializeStatus(timelineItem, {
63
- baseUrl,
64
- favouritedIds: interactionState?.favouritedIds || new Set(),
65
- rebloggedIds: interactionState?.rebloggedIds || new Set(),
66
- bookmarkedIds: interactionState?.bookmarkedIds || new Set(),
67
- pinnedIds: new Set(),
68
- });
61
+ if (statusMap) {
62
+ const isMentionType = mastodonType === "mention";
63
+ const lookupUrl = isMentionType
64
+ ? (notif.url || notif.targetUrl)
65
+ : (notif.targetUrl || notif.url);
66
+
67
+ if (lookupUrl) {
68
+ const timelineItem = statusMap.get(lookupUrl);
69
+ if (timelineItem) {
70
+ status = serializeStatus(timelineItem, {
71
+ baseUrl,
72
+ favouritedIds: interactionState?.favouritedIds || new Set(),
73
+ rebloggedIds: interactionState?.rebloggedIds || new Set(),
74
+ bookmarkedIds: interactionState?.bookmarkedIds || new Set(),
75
+ pinnedIds: new Set(),
76
+ });
77
+ }
69
78
  }
70
79
  }
71
80
 
@@ -82,7 +91,7 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
82
91
  visibility: notif.type === "dm" ? "direct" : "public",
83
92
  language: null,
84
93
  uri: notif.uid || "",
85
- url: notif.targetUrl || notif.uid || "",
94
+ url: notif.url || notif.targetUrl || notif.uid || "",
86
95
  replies_count: 0,
87
96
  reblogs_count: 0,
88
97
  favourites_count: 0,
@@ -110,7 +110,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
110
110
  if (follower) {
111
111
  return res.json(
112
112
  serializeAccount(
113
- { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle },
113
+ { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle, bannerUrl: follower.banner || "" },
114
114
  { baseUrl },
115
115
  ),
116
116
  );
@@ -277,7 +277,7 @@ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
277
277
 
278
278
  const accounts = followers.map((f) =>
279
279
  serializeAccount(
280
- { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
280
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
281
281
  { baseUrl },
282
282
  ),
283
283
  );
@@ -310,7 +310,7 @@ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
310
310
 
311
311
  const accounts = following.map((f) =>
312
312
  serializeAccount(
313
- { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
313
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
314
314
  { baseUrl },
315
315
  ),
316
316
  );
@@ -712,23 +712,35 @@ async function resolveActorUrl(id, collections) {
712
712
  * Returns { actor, actorUrl } or { actor: null, actorUrl: null }.
713
713
  */
714
714
  async function resolveActorData(id, collections) {
715
- // Check followers
715
+ // Check followers — pass through all stored fields for richer serialization
716
716
  const followers = await collections.ap_followers.find({}).toArray();
717
717
  for (const f of followers) {
718
718
  if (remoteActorId(f.actorUrl) === id) {
719
719
  return {
720
- actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
720
+ actor: {
721
+ name: f.name,
722
+ url: f.actorUrl,
723
+ photo: f.avatar,
724
+ handle: f.handle,
725
+ bannerUrl: f.banner || "",
726
+ },
721
727
  actorUrl: f.actorUrl,
722
728
  };
723
729
  }
724
730
  }
725
731
 
726
- // Check following
732
+ // Check following — pass through all stored fields
727
733
  const following = await collections.ap_following.find({}).toArray();
728
734
  for (const f of following) {
729
735
  if (remoteActorId(f.actorUrl) === id) {
730
736
  return {
731
- actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
737
+ actor: {
738
+ name: f.name,
739
+ url: f.actorUrl,
740
+ photo: f.avatar,
741
+ handle: f.handle,
742
+ bannerUrl: f.banner || "",
743
+ },
732
744
  actorUrl: f.actorUrl,
733
745
  };
734
746
  }
@@ -225,23 +225,25 @@ function resolveInternalTypes(mastodonTypes) {
225
225
  async function batchFetchStatuses(collections, notifications) {
226
226
  const statusMap = new Map();
227
227
 
228
- const targetUrls = [
228
+ // Collect both targetUrl (the post being acted on) and url (the triggering post)
229
+ // For mention/reply notifications, the url is the actual mention/reply post
230
+ const allUrls = [
229
231
  ...new Set(
230
232
  notifications
231
- .map((n) => n.targetUrl)
233
+ .flatMap((n) => [n.targetUrl, n.url])
232
234
  .filter(Boolean),
233
235
  ),
234
236
  ];
235
237
 
236
- if (targetUrls.length === 0 || !collections.ap_timeline) {
238
+ if (allUrls.length === 0 || !collections.ap_timeline) {
237
239
  return statusMap;
238
240
  }
239
241
 
240
242
  const items = await collections.ap_timeline
241
243
  .find({
242
244
  $or: [
243
- { uid: { $in: targetUrls } },
244
- { url: { $in: targetUrls } },
245
+ { uid: { $in: allUrls } },
246
+ { url: { $in: allUrls } },
245
247
  ],
246
248
  })
247
249
  .toArray();
@@ -219,10 +219,11 @@ router.get("/oauth/authorize", async (req, res, next) => {
219
219
  // Check if user is logged in via IndieAuth session
220
220
  const session = req.session;
221
221
  if (!session?.access_token && !force_login) {
222
- // Not logged in — redirect to Indiekit login, then back here
223
- const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
222
+ // Not logged in — redirect to Indiekit's login page, then back here.
223
+ // Indiekit uses /session/login?redirect=<path> (see indieauth.js authenticate()).
224
+ // The redirect value must be a local path (validated by regex in indieauth.js).
224
225
  return res.redirect(
225
- `/auth?redirect=${encodeURIComponent(returnUrl)}`,
226
+ `/session/login?redirect=${encodeURIComponent(req.originalUrl)}`,
226
227
  );
227
228
  }
228
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.4.0",
3
+ "version": "3.5.2",
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",