From c30657ef71dd37064b155fa58ba328d0bb7cb2f2 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 20:03:19 +0100 Subject: [PATCH] feat: surface moderation data in federation admin + Mastodon API 1. Federation admin page (/admin/federation): new Moderation section showing blocked servers (with hostnames), blocked accounts, and muted accounts/keywords 2. GET /api/v1/domain_blocks: returns actual blocked server hostnames from ap_blocked_servers (was stub returning []) 3. Relationship responses: domain_blocking field now checks if the account's domain matches a blocked server hostname (was always false) --- lib/controllers/federation-mgmt.js | 11 +++++-- lib/mastodon/routes/accounts.js | 20 ++++++++++-- lib/mastodon/routes/stubs.js | 11 +++++-- package.json | 2 +- views/activitypub-federation-mgmt.njk | 47 +++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/controllers/federation-mgmt.js b/lib/controllers/federation-mgmt.js index bda6a73..36ce931 100644 --- a/lib/controllers/federation-mgmt.js +++ b/lib/controllers/federation-mgmt.js @@ -41,12 +41,16 @@ export function federationMgmtController(mountPath, plugin) { const redisUrl = plugin.options.redisUrl || ""; - // Parallel: collection stats + posts + recent activities - const [collectionStats, postsResult, recentActivities] = + // Parallel: collection stats + posts + recent activities + moderation data + const pluginCollections = plugin._collections || {}; + const [collectionStats, postsResult, recentActivities, blockedServers, blockedAccounts, mutedAccounts] = await Promise.all([ getCollectionStats(collections, { redisUrl }), getPaginatedPosts(collections, request.query.page), getRecentActivities(collections), + pluginCollections.ap_blocked_servers?.find({}).sort({ blockedAt: -1 }).toArray() || [], + pluginCollections.ap_blocked?.find({}).sort({ blockedAt: -1 }).toArray() || [], + pluginCollections.ap_muted?.find({}).sort({ mutedAt: -1 }).toArray() || [], ]); const csrfToken = getToken(request.session); @@ -62,6 +66,9 @@ export function federationMgmtController(mountPath, plugin) { posts: postsResult.posts, cursor: postsResult.cursor, recentActivities, + blockedServers: blockedServers || [], + blockedAccounts: blockedAccounts || [], + mutedAccounts: mutedAccounts || [], csrfToken, mountPath, publicationUrl: plugin._publicationUrl, diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 66239cb..c9d3c1d 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -170,11 +170,12 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { const collections = req.app.locals.mastodonCollections; - const [followers, following, blocked, muted] = await Promise.all([ + const [followers, following, blocked, muted, blockedServers] = await Promise.all([ collections.ap_followers.find({}).toArray(), collections.ap_following.find({}).toArray(), collections.ap_blocked.find({}).toArray(), collections.ap_muted.find({}).toArray(), + collections.ap_blocked_servers?.find({}).toArray() || [], ]); const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl))); @@ -182,6 +183,21 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url))); const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url))); + // Build domain-blocked actor ID set by checking known actors against blocked server hostnames + const blockedDomains = new Set(blockedServers.map((s) => s.hostname).filter(Boolean)); + const domainBlockedIds = new Set(); + if (blockedDomains.size > 0) { + const allActors = [...followers, ...following]; + for (const actor of allActors) { + try { + const domain = new URL(actor.actorUrl).hostname; + if (blockedDomains.has(domain)) { + domainBlockedIds.add(remoteActorId(actor.actorUrl)); + } + } catch { /* skip invalid URLs */ } + } + } + const relationships = ids.map((id) => ({ id, following: followingIds.has(id), @@ -195,7 +211,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { muting_notifications: mutedIds.has(id), requested: false, requested_by: false, - domain_blocking: false, + domain_blocking: domainBlockedIds.has(id), endorsed: false, note: "", })); diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index eb9a73e..70c9458 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -314,8 +314,15 @@ router.get("/api/v1/conversations", (req, res) => { // ─── Domain blocks ────────────────────────────────────────────────────────── -router.get("/api/v1/domain_blocks", (req, res) => { - res.json([]); +router.get("/api/v1/domain_blocks", async (req, res) => { + try { + const collections = req.app.locals.mastodonCollections; + if (!collections?.ap_blocked_servers) return res.json([]); + const docs = await collections.ap_blocked_servers.find({}).toArray(); + res.json(docs.map((d) => d.hostname).filter(Boolean)); + } catch { + res.json([]); + } }); // ─── Endorsements ─────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index 98784ef..9798d17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.4", + "version": "3.7.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-federation-mgmt.njk b/views/activitypub-federation-mgmt.njk index dd52522..d76b37e 100644 --- a/views/activitypub-federation-mgmt.njk +++ b/views/activitypub-federation-mgmt.njk @@ -116,6 +116,53 @@ {% endif %} + {# --- Moderation Overview --- #} +
+

Moderation

+ {% if blockedServers.length > 0 %} +

Blocked servers ({{ blockedServers.length }})

+
+ {% for server in blockedServers %} +
+ 🚫 {{ server.hostname }} + {% if server.blockedAt %} + {{ server.blockedAt | date("PPp") }} + {% endif %} +
+ {% endfor %} +
+ {% else %} + {{ prose({ text: "No servers blocked." }) }} + {% endif %} + + {% if blockedAccounts.length > 0 %} +

Blocked accounts ({{ blockedAccounts.length }})

+
+ {% for account in blockedAccounts %} +
+ 🚫 {{ account.url or account.handle or "Unknown" }} + {% if account.blockedAt %} + {{ account.blockedAt | date("PPp") }} + {% endif %} +
+ {% endfor %} +
+ {% else %} + {{ prose({ text: "No accounts blocked." }) }} + {% endif %} + + {% if mutedAccounts.length > 0 %} +

Muted ({{ mutedAccounts.length }})

+
+ {% for muted in mutedAccounts %} +
+ 🔇 {{ muted.url or muted.keyword or "Unknown" }} +
+ {% endfor %} +
+ {% endif %} +
+ {# --- JSON Modal --- #}