@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 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 { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
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 [collectionStats, postsResult, recentActivities] =
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 { followTag, unfollowTag } from "../storage/followed-tags.js";
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);
@@ -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: updatedAvatar,
1015
- banner: updatedBanner,
996
+ avatar: actorObj.icon
997
+ ? (await actorObj.icon)?.url?.href || ""
998
+ : "",
1016
999
  updatedAt: new Date().toISOString(),
1017
1000
  },
1018
1001
  },
@@ -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: followerAvatar,
111
- banner: followerBanner,
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);
@@ -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: false,
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
- // 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 = [
228
+ const targetUrls = [
231
229
  ...new Set(
232
230
  notifications
233
- .flatMap((n) => [n.targetUrl, n.url])
231
+ .map((n) => n.targetUrl)
234
232
  .filter(Boolean),
235
233
  ),
236
234
  ];
237
235
 
238
- if (allUrls.length === 0 || !collections.ap_timeline) {
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: allUrls } },
246
- { url: { $in: allUrls } },
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
- res.json([]);
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
- res.json([]);
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 ───────────────────────────────────────────────────────────
@@ -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 col = (name) => collections.get?.(name) ?? collections[name];
97
- const ap_timeline = col("ap_timeline");
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.7.4",
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>