@rmdes/indiekit-endpoint-activitypub 3.7.4 → 3.8.0
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/README.md +19 -1
- package/index.js +8 -1
- package/lib/controllers/federation-mgmt.js +9 -2
- package/lib/controllers/follow-tag.js +85 -1
- package/lib/controllers/tag-timeline.js +7 -2
- package/lib/inbox-handlers.js +3 -20
- package/lib/inbox-listeners.js +3 -43
- package/lib/inbox-queue.js +0 -21
- package/lib/mastodon/routes/accounts.js +18 -2
- package/lib/mastodon/routes/notifications.js +5 -7
- package/lib/mastodon/routes/stubs.js +33 -4
- package/lib/resolve-author.js +2 -4
- package/lib/storage/followed-tags.js +86 -2
- package/locales/en.json +5 -1
- package/package.json +1 -1
- package/views/activitypub-federation-mgmt.njk +47 -0
- package/views/activitypub-tag-timeline.njk +21 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rmdes/indiekit-endpoint-activitypub
|
|
2
2
|
|
|
3
|
-
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform.
|
|
3
|
+
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -79,6 +79,23 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
79
79
|
- OpenTelemetry tracing for federation activity
|
|
80
80
|
- Real-time activity inspection
|
|
81
81
|
|
|
82
|
+
**Mastodon Client API** *(v3.0.0+)*
|
|
83
|
+
- Full Mastodon REST API compatibility — use Phanpy, Elk, Moshidon, Fedilab, or any Mastodon-compatible client
|
|
84
|
+
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
|
|
85
|
+
- HTML+JS redirect for native Android apps (Chrome Custom Tabs block 302 to custom URI schemes)
|
|
86
|
+
- Home, public, and hashtag timelines with chronological published-date pagination
|
|
87
|
+
- Status creation via Micropub pipeline — posts flow through Indiekit → content file → AP syndication
|
|
88
|
+
- URL auto-linkification and @mention extraction in posted content
|
|
89
|
+
- Thread context (ancestors + descendants)
|
|
90
|
+
- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections
|
|
91
|
+
- Account stats enrichment — embedded account data in timeline responses includes real counts
|
|
92
|
+
- Favourite, boost, bookmark interactions federated via Fedify AP activities
|
|
93
|
+
- Notifications with type filtering
|
|
94
|
+
- Search across accounts, statuses, and hashtags with remote resolution
|
|
95
|
+
- Domain blocks API
|
|
96
|
+
- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content)
|
|
97
|
+
- In-memory account stats cache (500 entries, 1h TTL) for performance
|
|
98
|
+
|
|
82
99
|
**Admin UI**
|
|
83
100
|
- Dashboard with follower/following counts and recent activity
|
|
84
101
|
- Profile editor (name, bio, avatar, header, profile links with rel="me" verification)
|
|
@@ -86,6 +103,7 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
86
103
|
- Featured tags (hashtag collection)
|
|
87
104
|
- Activity log (inbound/outbound)
|
|
88
105
|
- Follower and following lists with source tracking
|
|
106
|
+
- Federation management page with moderation overview (blocked servers, blocked accounts, muted)
|
|
89
107
|
|
|
90
108
|
## Requirements
|
|
91
109
|
|
package/index.js
CHANGED
|
@@ -79,7 +79,12 @@ import {
|
|
|
79
79
|
instanceCheckApiController,
|
|
80
80
|
popularAccountsApiController,
|
|
81
81
|
} from "./lib/controllers/explore.js";
|
|
82
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
followTagController,
|
|
84
|
+
unfollowTagController,
|
|
85
|
+
followTagGloballyController,
|
|
86
|
+
unfollowTagGloballyController,
|
|
87
|
+
} from "./lib/controllers/follow-tag.js";
|
|
83
88
|
import {
|
|
84
89
|
listTabsController,
|
|
85
90
|
addTabController,
|
|
@@ -296,6 +301,8 @@ export default class ActivityPubEndpoint {
|
|
|
296
301
|
router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
|
|
297
302
|
router.post("/admin/reader/follow-tag", followTagController(mp));
|
|
298
303
|
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
|
304
|
+
router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this));
|
|
305
|
+
router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this));
|
|
299
306
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
|
300
307
|
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
|
301
308
|
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
|
@@ -41,12 +41,16 @@ export function federationMgmtController(mountPath, plugin) {
|
|
|
41
41
|
|
|
42
42
|
const redisUrl = plugin.options.redisUrl || "";
|
|
43
43
|
|
|
44
|
-
// Parallel: collection stats + posts + recent activities
|
|
45
|
-
const
|
|
44
|
+
// Parallel: collection stats + posts + recent activities + moderation data
|
|
45
|
+
const pluginCollections = plugin._collections || {};
|
|
46
|
+
const [collectionStats, postsResult, recentActivities, blockedServers, blockedAccounts, mutedAccounts] =
|
|
46
47
|
await Promise.all([
|
|
47
48
|
getCollectionStats(collections, { redisUrl }),
|
|
48
49
|
getPaginatedPosts(collections, request.query.page),
|
|
49
50
|
getRecentActivities(collections),
|
|
51
|
+
pluginCollections.ap_blocked_servers?.find({}).sort({ blockedAt: -1 }).toArray() || [],
|
|
52
|
+
pluginCollections.ap_blocked?.find({}).sort({ blockedAt: -1 }).toArray() || [],
|
|
53
|
+
pluginCollections.ap_muted?.find({}).sort({ mutedAt: -1 }).toArray() || [],
|
|
50
54
|
]);
|
|
51
55
|
|
|
52
56
|
const csrfToken = getToken(request.session);
|
|
@@ -62,6 +66,9 @@ export function federationMgmtController(mountPath, plugin) {
|
|
|
62
66
|
posts: postsResult.posts,
|
|
63
67
|
cursor: postsResult.cursor,
|
|
64
68
|
recentActivities,
|
|
69
|
+
blockedServers: blockedServers || [],
|
|
70
|
+
blockedAccounts: blockedAccounts || [],
|
|
71
|
+
mutedAccounts: mutedAccounts || [],
|
|
65
72
|
csrfToken,
|
|
66
73
|
mountPath,
|
|
67
74
|
publicationUrl: plugin._publicationUrl,
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { validateToken } from "../csrf.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
followTag,
|
|
8
|
+
unfollowTag,
|
|
9
|
+
setGlobalFollow,
|
|
10
|
+
removeGlobalFollow,
|
|
11
|
+
getTagsPubActorUrl,
|
|
12
|
+
} from "../storage/followed-tags.js";
|
|
7
13
|
|
|
8
14
|
export function followTagController(mountPath) {
|
|
9
15
|
return async (request, response, next) => {
|
|
@@ -60,3 +66,81 @@ export function unfollowTagController(mountPath) {
|
|
|
60
66
|
}
|
|
61
67
|
};
|
|
62
68
|
}
|
|
69
|
+
|
|
70
|
+
export function followTagGloballyController(mountPath, plugin) {
|
|
71
|
+
return async (request, response, next) => {
|
|
72
|
+
try {
|
|
73
|
+
const { application } = request.app.locals;
|
|
74
|
+
|
|
75
|
+
// CSRF validation
|
|
76
|
+
if (!validateToken(request)) {
|
|
77
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
81
|
+
if (!tag) {
|
|
82
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const actorUrl = getTagsPubActorUrl(tag);
|
|
86
|
+
|
|
87
|
+
// Send AP Follow activity via Fedify
|
|
88
|
+
const result = await plugin.followActor(actorUrl);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
const errorMsg = encodeURIComponent(result.error || "Follow failed");
|
|
91
|
+
return response.redirect(
|
|
92
|
+
`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Store global follow state
|
|
97
|
+
const collections = {
|
|
98
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
99
|
+
};
|
|
100
|
+
await setGlobalFollow(collections, tag, actorUrl);
|
|
101
|
+
|
|
102
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
next(error);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function unfollowTagGloballyController(mountPath, plugin) {
|
|
110
|
+
return async (request, response, next) => {
|
|
111
|
+
try {
|
|
112
|
+
const { application } = request.app.locals;
|
|
113
|
+
|
|
114
|
+
// CSRF validation
|
|
115
|
+
if (!validateToken(request)) {
|
|
116
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
120
|
+
if (!tag) {
|
|
121
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const actorUrl = getTagsPubActorUrl(tag);
|
|
125
|
+
|
|
126
|
+
// Send AP Undo(Follow) activity via Fedify
|
|
127
|
+
const result = await plugin.unfollowActor(actorUrl);
|
|
128
|
+
if (!result.ok) {
|
|
129
|
+
const errorMsg = encodeURIComponent(result.error || "Unfollow failed");
|
|
130
|
+
return response.redirect(
|
|
131
|
+
`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Remove global follow state
|
|
136
|
+
const collections = {
|
|
137
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
138
|
+
};
|
|
139
|
+
await removeGlobalFollow(collections, tag);
|
|
140
|
+
|
|
141
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
next(error);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -45,17 +45,20 @@ export function tagTimelineController(mountPath) {
|
|
|
45
45
|
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
// Check if this hashtag is followed
|
|
48
|
+
// Check if this hashtag is followed (local and/or global)
|
|
49
49
|
const followedTagsCol = application?.collections?.get("ap_followed_tags");
|
|
50
50
|
let isFollowed = false;
|
|
51
|
+
let isGloballyFollowed = false;
|
|
51
52
|
if (followedTagsCol) {
|
|
52
53
|
const followed = await followedTagsCol.findOne({
|
|
53
54
|
tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }
|
|
54
55
|
});
|
|
55
|
-
isFollowed = !!followed;
|
|
56
|
+
isFollowed = !!(followed?.followedAt);
|
|
57
|
+
isGloballyFollowed = !!(followed?.globalFollow);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const csrfToken = getToken(request.session);
|
|
61
|
+
const error = typeof request.query.error === "string" ? request.query.error : null;
|
|
59
62
|
|
|
60
63
|
response.render("activitypub-tag-timeline", {
|
|
61
64
|
title: `#${tag}`,
|
|
@@ -68,6 +71,8 @@ export function tagTimelineController(mountPath) {
|
|
|
68
71
|
csrfToken,
|
|
69
72
|
mountPath,
|
|
70
73
|
isFollowed,
|
|
74
|
+
isGloballyFollowed,
|
|
75
|
+
error,
|
|
71
76
|
});
|
|
72
77
|
} catch (error) {
|
|
73
78
|
next(error);
|
package/lib/inbox-handlers.js
CHANGED
|
@@ -984,24 +984,6 @@ 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
|
-
|
|
1005
987
|
await collections.ap_followers.updateOne(
|
|
1006
988
|
{ actorUrl },
|
|
1007
989
|
{
|
|
@@ -1011,8 +993,9 @@ export async function handleUpdate(item, collections, ctx, handle) {
|
|
|
1011
993
|
actorObj.preferredUsername?.toString() ||
|
|
1012
994
|
actorUrl,
|
|
1013
995
|
handle: actorObj.preferredUsername?.toString() || "",
|
|
1014
|
-
avatar:
|
|
1015
|
-
|
|
996
|
+
avatar: actorObj.icon
|
|
997
|
+
? (await actorObj.icon)?.url?.href || ""
|
|
998
|
+
: "",
|
|
1016
999
|
updatedAt: new Date().toISOString(),
|
|
1017
1000
|
},
|
|
1018
1001
|
},
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -46,31 +46,12 @@ 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
|
-
|
|
67
49
|
inboxChain
|
|
68
50
|
// ── Follow ──────────────────────────────────────────────────────
|
|
69
51
|
// Synchronous: Accept/Reject + follower storage (federation requirement)
|
|
70
52
|
// Async: notification + activity log
|
|
71
53
|
.on(Follow, async (ctx, follow) => {
|
|
72
54
|
const actorUrl = follow.actorId?.href || "";
|
|
73
|
-
_diagTrack("Follow", actorUrl);
|
|
74
55
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
75
56
|
await touchKeyFreshness(collections, actorUrl);
|
|
76
57
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -85,30 +66,13 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
85
66
|
followerActor.preferredUsername?.toString() ||
|
|
86
67
|
followerUrl;
|
|
87
68
|
|
|
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
|
-
|
|
106
69
|
const followerData = {
|
|
107
70
|
actorUrl: followerUrl,
|
|
108
71
|
handle: followerActor.preferredUsername?.toString() || "",
|
|
109
72
|
name: followerName,
|
|
110
|
-
avatar:
|
|
111
|
-
|
|
73
|
+
avatar: followerActor.icon
|
|
74
|
+
? (await followerActor.icon)?.url?.href || ""
|
|
75
|
+
: "",
|
|
112
76
|
inbox: followerActor.inbox?.id?.href || "",
|
|
113
77
|
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
|
114
78
|
};
|
|
@@ -245,7 +209,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
245
209
|
// ── Announce ────────────────────────────────────────────────────
|
|
246
210
|
.on(Announce, async (ctx, announce) => {
|
|
247
211
|
const actorUrl = announce.actorId?.href || "";
|
|
248
|
-
_diagTrack("Announce", actorUrl);
|
|
249
212
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
250
213
|
await touchKeyFreshness(collections, actorUrl);
|
|
251
214
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -261,7 +224,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
261
224
|
// ── Create ──────────────────────────────────────────────────────
|
|
262
225
|
.on(Create, async (ctx, create) => {
|
|
263
226
|
const actorUrl = create.actorId?.href || "";
|
|
264
|
-
_diagTrack("Create", actorUrl);
|
|
265
227
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
266
228
|
await touchKeyFreshness(collections, actorUrl);
|
|
267
229
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -312,7 +274,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
312
274
|
// ── Delete ──────────────────────────────────────────────────────
|
|
313
275
|
.on(Delete, async (ctx, del) => {
|
|
314
276
|
const actorUrl = del.actorId?.href || "";
|
|
315
|
-
_diagTrack("Delete", actorUrl);
|
|
316
277
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
317
278
|
await touchKeyFreshness(collections, actorUrl);
|
|
318
279
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -342,7 +303,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
342
303
|
// ── Update ──────────────────────────────────────────────────────
|
|
343
304
|
.on(Update, async (ctx, update) => {
|
|
344
305
|
const actorUrl = update.actorId?.href || "";
|
|
345
|
-
_diagTrack("Update", actorUrl);
|
|
346
306
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
347
307
|
await touchKeyFreshness(collections, actorUrl);
|
|
348
308
|
await resetDeliveryStrikes(collections, actorUrl);
|
package/lib/inbox-queue.js
CHANGED
|
@@ -83,32 +83,11 @@ 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
|
-
|
|
100
86
|
const intervalId = setInterval(async () => {
|
|
101
87
|
try {
|
|
102
88
|
const ctx = getCtx();
|
|
103
89
|
if (ctx) {
|
|
104
|
-
const before = Date.now();
|
|
105
90
|
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
|
-
}
|
|
112
91
|
}
|
|
113
92
|
} catch (error) {
|
|
114
93
|
console.error("[inbox-queue] Processor error:", error.message);
|
|
@@ -170,11 +170,12 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
|
|
|
170
170
|
|
|
171
171
|
const collections = req.app.locals.mastodonCollections;
|
|
172
172
|
|
|
173
|
-
const [followers, following, blocked, muted] = await Promise.all([
|
|
173
|
+
const [followers, following, blocked, muted, blockedServers] = await Promise.all([
|
|
174
174
|
collections.ap_followers.find({}).toArray(),
|
|
175
175
|
collections.ap_following.find({}).toArray(),
|
|
176
176
|
collections.ap_blocked.find({}).toArray(),
|
|
177
177
|
collections.ap_muted.find({}).toArray(),
|
|
178
|
+
collections.ap_blocked_servers?.find({}).toArray() || [],
|
|
178
179
|
]);
|
|
179
180
|
|
|
180
181
|
const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
|
|
@@ -182,6 +183,21 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
|
|
|
182
183
|
const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
|
|
183
184
|
const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
|
|
184
185
|
|
|
186
|
+
// Build domain-blocked actor ID set by checking known actors against blocked server hostnames
|
|
187
|
+
const blockedDomains = new Set(blockedServers.map((s) => s.hostname).filter(Boolean));
|
|
188
|
+
const domainBlockedIds = new Set();
|
|
189
|
+
if (blockedDomains.size > 0) {
|
|
190
|
+
const allActors = [...followers, ...following];
|
|
191
|
+
for (const actor of allActors) {
|
|
192
|
+
try {
|
|
193
|
+
const domain = new URL(actor.actorUrl).hostname;
|
|
194
|
+
if (blockedDomains.has(domain)) {
|
|
195
|
+
domainBlockedIds.add(remoteActorId(actor.actorUrl));
|
|
196
|
+
}
|
|
197
|
+
} catch { /* skip invalid URLs */ }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
185
201
|
const relationships = ids.map((id) => ({
|
|
186
202
|
id,
|
|
187
203
|
following: followingIds.has(id),
|
|
@@ -195,7 +211,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
|
|
|
195
211
|
muting_notifications: mutedIds.has(id),
|
|
196
212
|
requested: false,
|
|
197
213
|
requested_by: false,
|
|
198
|
-
domain_blocking:
|
|
214
|
+
domain_blocking: domainBlockedIds.has(id),
|
|
199
215
|
endorsed: false,
|
|
200
216
|
note: "",
|
|
201
217
|
}));
|
|
@@ -225,25 +225,23 @@ function resolveInternalTypes(mastodonTypes) {
|
|
|
225
225
|
async function batchFetchStatuses(collections, notifications) {
|
|
226
226
|
const statusMap = new Map();
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
// For mention/reply notifications, the url is the actual mention/reply post
|
|
230
|
-
const allUrls = [
|
|
228
|
+
const targetUrls = [
|
|
231
229
|
...new Set(
|
|
232
230
|
notifications
|
|
233
|
-
.
|
|
231
|
+
.map((n) => n.targetUrl)
|
|
234
232
|
.filter(Boolean),
|
|
235
233
|
),
|
|
236
234
|
];
|
|
237
235
|
|
|
238
|
-
if (
|
|
236
|
+
if (targetUrls.length === 0 || !collections.ap_timeline) {
|
|
239
237
|
return statusMap;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
240
|
const items = await collections.ap_timeline
|
|
243
241
|
.find({
|
|
244
242
|
$or: [
|
|
245
|
-
{ uid: { $in:
|
|
246
|
-
{ url: { $in:
|
|
243
|
+
{ uid: { $in: targetUrls } },
|
|
244
|
+
{ url: { $in: targetUrls } },
|
|
247
245
|
],
|
|
248
246
|
})
|
|
249
247
|
.toArray();
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import express from "express";
|
|
22
22
|
import { serializeStatus } from "../entities/status.js";
|
|
23
23
|
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
|
|
24
|
+
import { getFollowedTagsWithState } from "../../storage/followed-tags.js";
|
|
24
25
|
|
|
25
26
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
26
27
|
|
|
@@ -276,8 +277,29 @@ router.get("/api/v1/featured_tags", (req, res) => {
|
|
|
276
277
|
|
|
277
278
|
// ─── Followed tags ──────────────────────────────────────────────────────────
|
|
278
279
|
|
|
279
|
-
router.get("/api/v1/followed_tags", (req, res) => {
|
|
280
|
-
|
|
280
|
+
router.get("/api/v1/followed_tags", async (req, res, next) => {
|
|
281
|
+
try {
|
|
282
|
+
const collections = req.app.locals.mastodonCollections;
|
|
283
|
+
if (!collections?.ap_followed_tags) {
|
|
284
|
+
return res.json([]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
288
|
+
const publicationUrl = pluginOptions.publicationUrl || "";
|
|
289
|
+
const tags = await getFollowedTagsWithState({ ap_followed_tags: collections.ap_followed_tags });
|
|
290
|
+
|
|
291
|
+
const response = tags.map((doc) => ({
|
|
292
|
+
id: doc._id.toString(),
|
|
293
|
+
name: doc.tag,
|
|
294
|
+
url: `${publicationUrl.replace(/\/$/, "")}/tags/${doc.tag}`,
|
|
295
|
+
history: [],
|
|
296
|
+
following: true,
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
res.json(response);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
next(error);
|
|
302
|
+
}
|
|
281
303
|
});
|
|
282
304
|
|
|
283
305
|
// ─── Suggestions ────────────────────────────────────────────────────────────
|
|
@@ -314,8 +336,15 @@ router.get("/api/v1/conversations", (req, res) => {
|
|
|
314
336
|
|
|
315
337
|
// ─── Domain blocks ──────────────────────────────────────────────────────────
|
|
316
338
|
|
|
317
|
-
router.get("/api/v1/domain_blocks", (req, res) => {
|
|
318
|
-
|
|
339
|
+
router.get("/api/v1/domain_blocks", async (req, res) => {
|
|
340
|
+
try {
|
|
341
|
+
const collections = req.app.locals.mastodonCollections;
|
|
342
|
+
if (!collections?.ap_blocked_servers) return res.json([]);
|
|
343
|
+
const docs = await collections.ap_blocked_servers.find({}).toArray();
|
|
344
|
+
res.json(docs.map((d) => d.hostname).filter(Boolean));
|
|
345
|
+
} catch {
|
|
346
|
+
res.json([]);
|
|
347
|
+
}
|
|
319
348
|
});
|
|
320
349
|
|
|
321
350
|
// ─── Endorsements ───────────────────────────────────────────────────────────
|
package/lib/resolve-author.js
CHANGED
|
@@ -91,11 +91,9 @@ export async function resolveAuthor(
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// Strategy 2: Use author URL from timeline or notifications
|
|
94
|
-
// Support both Map (admin controllers) and plain object (Mastodon API)
|
|
95
94
|
if (collections) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const ap_notifications = col("ap_notifications");
|
|
95
|
+
const ap_timeline = collections.get("ap_timeline");
|
|
96
|
+
const ap_notifications = collections.get("ap_notifications");
|
|
99
97
|
|
|
100
98
|
// Search timeline by both uid (canonical) and url (display)
|
|
101
99
|
let authorUrl = null;
|
|
@@ -15,6 +15,17 @@ export async function getFollowedTags(collections) {
|
|
|
15
15
|
return docs.map((d) => d.tag);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Get all followed hashtags with full state (local + global follow tracking)
|
|
20
|
+
* @param {object} collections - MongoDB collections
|
|
21
|
+
* @returns {Promise<Array<{tag: string, followedAt?: string, globalFollow?: boolean, globalActorUrl?: string}>>}
|
|
22
|
+
*/
|
|
23
|
+
export async function getFollowedTagsWithState(collections) {
|
|
24
|
+
const { ap_followed_tags } = collections;
|
|
25
|
+
if (!ap_followed_tags) return [];
|
|
26
|
+
return ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray();
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Follow a hashtag
|
|
20
31
|
* @param {object} collections - MongoDB collections
|
|
@@ -36,16 +47,31 @@ export async function followTag(collections, tag) {
|
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
|
-
* Unfollow a hashtag
|
|
50
|
+
* Unfollow a hashtag locally.
|
|
51
|
+
* If a global follow (tags.pub) is active, preserves the document with global state intact.
|
|
52
|
+
* Only deletes the document entirely when no global follow is active.
|
|
40
53
|
* @param {object} collections - MongoDB collections
|
|
41
54
|
* @param {string} tag - Hashtag string (without # prefix)
|
|
42
|
-
* @returns {Promise<boolean>} true if removed, false if not found
|
|
55
|
+
* @returns {Promise<boolean>} true if removed/updated, false if not found
|
|
43
56
|
*/
|
|
44
57
|
export async function unfollowTag(collections, tag) {
|
|
45
58
|
const { ap_followed_tags } = collections;
|
|
46
59
|
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
47
60
|
if (!normalizedTag) return false;
|
|
48
61
|
|
|
62
|
+
// Check if a global follow is active before deleting
|
|
63
|
+
const existing = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
64
|
+
if (!existing) return false;
|
|
65
|
+
|
|
66
|
+
if (existing.globalFollow) {
|
|
67
|
+
// Preserve the document — only unset the local follow fields
|
|
68
|
+
await ap_followed_tags.updateOne(
|
|
69
|
+
{ tag: normalizedTag },
|
|
70
|
+
{ $unset: { followedAt: "" } }
|
|
71
|
+
);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
const result = await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
|
50
76
|
return result.deletedCount > 0;
|
|
51
77
|
}
|
|
@@ -63,3 +89,61 @@ export async function isTagFollowed(collections, tag) {
|
|
|
63
89
|
const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
64
90
|
return !!doc;
|
|
65
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns the deterministic tags.pub actor URL for a hashtag.
|
|
95
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
96
|
+
* @returns {string} Actor URL
|
|
97
|
+
*/
|
|
98
|
+
export function getTagsPubActorUrl(tag) {
|
|
99
|
+
return `https://tags.pub/user/${tag.toLowerCase()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set global follow state for a hashtag (upsert — works even with no local follow).
|
|
104
|
+
* @param {object} collections - MongoDB collections
|
|
105
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
106
|
+
* @param {string} actorUrl - The tags.pub actor URL
|
|
107
|
+
* @returns {Promise<void>}
|
|
108
|
+
*/
|
|
109
|
+
export async function setGlobalFollow(collections, tag, actorUrl) {
|
|
110
|
+
const { ap_followed_tags } = collections;
|
|
111
|
+
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
112
|
+
if (!normalizedTag) return;
|
|
113
|
+
|
|
114
|
+
await ap_followed_tags.updateOne(
|
|
115
|
+
{ tag: normalizedTag },
|
|
116
|
+
{
|
|
117
|
+
$set: { globalFollow: true, globalActorUrl: actorUrl },
|
|
118
|
+
$setOnInsert: { tag: normalizedTag },
|
|
119
|
+
},
|
|
120
|
+
{ upsert: true }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove global follow state for a hashtag.
|
|
126
|
+
* If no local follow exists (no followedAt), deletes the document entirely.
|
|
127
|
+
* @param {object} collections - MongoDB collections
|
|
128
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
export async function removeGlobalFollow(collections, tag) {
|
|
132
|
+
const { ap_followed_tags } = collections;
|
|
133
|
+
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
134
|
+
if (!normalizedTag) return;
|
|
135
|
+
|
|
136
|
+
const existing = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
137
|
+
if (!existing) return;
|
|
138
|
+
|
|
139
|
+
if (existing.followedAt) {
|
|
140
|
+
// Local follow is still active — just unset the global fields
|
|
141
|
+
await ap_followed_tags.updateOne(
|
|
142
|
+
{ tag: normalizedTag },
|
|
143
|
+
{ $unset: { globalFollow: "", globalActorUrl: "" } }
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
// No local follow — delete the document entirely
|
|
147
|
+
await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
|
148
|
+
}
|
|
149
|
+
}
|
package/locales/en.json
CHANGED
|
@@ -297,7 +297,11 @@
|
|
|
297
297
|
"noPosts": "No posts found with #%s in your timeline.",
|
|
298
298
|
"followTag": "Follow hashtag",
|
|
299
299
|
"unfollowTag": "Unfollow hashtag",
|
|
300
|
-
"following": "Following"
|
|
300
|
+
"following": "Following",
|
|
301
|
+
"followGlobally": "Follow globally via tags.pub",
|
|
302
|
+
"unfollowGlobally": "Unfollow global",
|
|
303
|
+
"globallyFollowing": "Following globally",
|
|
304
|
+
"globalFollowError": "Failed to follow globally: %s"
|
|
301
305
|
},
|
|
302
306
|
"pagination": {
|
|
303
307
|
"newer": "← Newer",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
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",
|
|
@@ -116,6 +116,53 @@
|
|
|
116
116
|
{% endif %}
|
|
117
117
|
</section>
|
|
118
118
|
|
|
119
|
+
{# --- Moderation Overview --- #}
|
|
120
|
+
<section class="ap-federation__section">
|
|
121
|
+
<h2>Moderation</h2>
|
|
122
|
+
{% if blockedServers.length > 0 %}
|
|
123
|
+
<h3>Blocked servers ({{ blockedServers.length }})</h3>
|
|
124
|
+
<div class="ap-federation__stats-grid">
|
|
125
|
+
{% for server in blockedServers %}
|
|
126
|
+
<div class="ap-federation__stat-card">
|
|
127
|
+
<span class="ap-federation__stat-label">🚫 {{ server.hostname }}</span>
|
|
128
|
+
{% if server.blockedAt %}
|
|
129
|
+
<span class="ap-federation__stat-count" style="font-size: 0.75rem; opacity: 0.7">{{ server.blockedAt | date("PPp") }}</span>
|
|
130
|
+
{% endif %}
|
|
131
|
+
</div>
|
|
132
|
+
{% endfor %}
|
|
133
|
+
</div>
|
|
134
|
+
{% else %}
|
|
135
|
+
{{ prose({ text: "No servers blocked." }) }}
|
|
136
|
+
{% endif %}
|
|
137
|
+
|
|
138
|
+
{% if blockedAccounts.length > 0 %}
|
|
139
|
+
<h3>Blocked accounts ({{ blockedAccounts.length }})</h3>
|
|
140
|
+
<div class="ap-federation__stats-grid">
|
|
141
|
+
{% for account in blockedAccounts %}
|
|
142
|
+
<div class="ap-federation__stat-card">
|
|
143
|
+
<span class="ap-federation__stat-label">🚫 {{ account.url or account.handle or "Unknown" }}</span>
|
|
144
|
+
{% if account.blockedAt %}
|
|
145
|
+
<span class="ap-federation__stat-count" style="font-size: 0.75rem; opacity: 0.7">{{ account.blockedAt | date("PPp") }}</span>
|
|
146
|
+
{% endif %}
|
|
147
|
+
</div>
|
|
148
|
+
{% endfor %}
|
|
149
|
+
</div>
|
|
150
|
+
{% else %}
|
|
151
|
+
{{ prose({ text: "No accounts blocked." }) }}
|
|
152
|
+
{% endif %}
|
|
153
|
+
|
|
154
|
+
{% if mutedAccounts.length > 0 %}
|
|
155
|
+
<h3>Muted ({{ mutedAccounts.length }})</h3>
|
|
156
|
+
<div class="ap-federation__stats-grid">
|
|
157
|
+
{% for muted in mutedAccounts %}
|
|
158
|
+
<div class="ap-federation__stat-card">
|
|
159
|
+
<span class="ap-federation__stat-label">🔇 {{ muted.url or muted.keyword or "Unknown" }}</span>
|
|
160
|
+
</div>
|
|
161
|
+
{% endfor %}
|
|
162
|
+
</div>
|
|
163
|
+
{% endif %}
|
|
164
|
+
</section>
|
|
165
|
+
|
|
119
166
|
{# --- JSON Modal --- #}
|
|
120
167
|
<div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
|
|
121
168
|
@click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
{# Tag header #}
|
|
5
5
|
<header class="ap-tag-header">
|
|
6
6
|
<div class="ap-tag-header__info">
|
|
7
|
-
<h2 class="ap-tag-header__title">#{{ hashtag }}</h2>
|
|
7
|
+
<h2 class="ap-tag-header__title">#{{ hashtag }}{% if isGloballyFollowed %} <span class="ap-tag-header__global-badge" title="{{ __('activitypub.reader.tagTimeline.globallyFollowing') }}">🌐</span>{% endif %}</h2>
|
|
8
8
|
<p class="ap-tag-header__count">
|
|
9
9
|
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
12
|
<div class="ap-tag-header__actions">
|
|
13
|
+
{% if error %}
|
|
14
|
+
<p class="ap-tag-header__error">{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}</p>
|
|
15
|
+
{% endif %}
|
|
13
16
|
{% if isFollowed %}
|
|
14
17
|
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
|
|
15
18
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
@@ -27,6 +30,23 @@
|
|
|
27
30
|
</button>
|
|
28
31
|
</form>
|
|
29
32
|
{% endif %}
|
|
33
|
+
{% if isGloballyFollowed %}
|
|
34
|
+
<form action="{{ mountPath }}/admin/reader/unfollow-tag-global" method="post" class="ap-tag-header__follow-form">
|
|
35
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
36
|
+
<input type="hidden" name="tag" value="{{ hashtag }}">
|
|
37
|
+
<button type="submit" class="ap-tag-header__unfollow-btn ap-tag-header__unfollow-btn--global">
|
|
38
|
+
🌐 {{ __("activitypub.reader.tagTimeline.unfollowGlobally") }}
|
|
39
|
+
</button>
|
|
40
|
+
</form>
|
|
41
|
+
{% else %}
|
|
42
|
+
<form action="{{ mountPath }}/admin/reader/follow-tag-global" method="post" class="ap-tag-header__follow-form">
|
|
43
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
44
|
+
<input type="hidden" name="tag" value="{{ hashtag }}">
|
|
45
|
+
<button type="submit" class="ap-tag-header__follow-btn ap-tag-header__follow-btn--global">
|
|
46
|
+
🌐 {{ __("activitypub.reader.tagTimeline.followGlobally") }}
|
|
47
|
+
</button>
|
|
48
|
+
</form>
|
|
49
|
+
{% endif %}
|
|
30
50
|
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
|
|
31
51
|
← {{ __("activitypub.reader.title") }}
|
|
32
52
|
</a>
|