@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 +8 -0
- package/lib/inbox-handlers.js +20 -3
- package/lib/inbox-listeners.js +43 -3
- package/lib/inbox-queue.js +21 -0
- package/lib/mastodon/entities/notification.js +20 -11
- package/lib/mastodon/routes/accounts.js +19 -7
- package/lib/mastodon/routes/notifications.js +7 -5
- package/lib/mastodon/routes/oauth.js +4 -3
- package/package.json +1 -1
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
|
|
package/lib/inbox-handlers.js
CHANGED
|
@@ -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:
|
|
997
|
-
|
|
998
|
-
: "",
|
|
1014
|
+
avatar: updatedAvatar,
|
|
1015
|
+
banner: updatedBanner,
|
|
999
1016
|
updatedAt: new Date().toISOString(),
|
|
1000
1017
|
},
|
|
1001
1018
|
},
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -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:
|
|
74
|
-
|
|
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);
|
package/lib/inbox-queue.js
CHANGED
|
@@ -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 (
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
.
|
|
233
|
+
.flatMap((n) => [n.targetUrl, n.url])
|
|
232
234
|
.filter(Boolean),
|
|
233
235
|
),
|
|
234
236
|
];
|
|
235
237
|
|
|
236
|
-
if (
|
|
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:
|
|
244
|
-
{ url: { $in:
|
|
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
|
-
|
|
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
|
-
`/
|
|
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.
|
|
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",
|