Files
indiekit-endpoint-activitypub/lib/controllers/moderation.js
Ricardo 1567b7c4e5 feat: operational resilience hardening — server blocking, caching, key refresh, async inbox (v2.14.0)
- Server-level blocking: O(1) Redis SISMEMBER check in all inbox listeners,
  admin UI for blocking/unblocking servers by hostname, MongoDB fallback
- Redis caching for collection dispatchers: 300s TTL on followers/following/liked
  counters and paginated pages, one-shot followers recipients cache
- Proactive key refresh: daily cron re-fetches actor documents for followers
  with 7+ day stale keys using lookupWithSecurity()
- Async inbox processing: MongoDB-backed queue with 3s polling, retry (3 attempts),
  24h TTL auto-prune. Follow keeps synchronous Accept, Block keeps synchronous
  follower removal. All other activity types fully deferred to background processor.

Inspired by wafrn's battle-tested multi-user AP implementation.

Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
2026-03-17 09:16:05 +01:00

432 lines
12 KiB
JavaScript

/**
* Moderation controllers — Mute, Unmute, Block, Unblock.
*/
import { validateToken, getToken } from "../csrf.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
import {
addMuted,
removeMuted,
addBlocked,
removeBlocked,
getAllMuted,
getAllBlocked,
getFilterMode,
setFilterMode,
} from "../storage/moderation.js";
import {
addBlockedServer,
removeBlockedServer,
getAllBlockedServers,
} from "../storage/server-blocks.js";
/**
* Helper to get moderation collections from request.
*/
function getModerationCollections(request) {
const { application } = request.app.locals;
return {
ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"),
ap_blocked_servers: application?.collections?.get("ap_blocked_servers"),
ap_timeline: application?.collections?.get("ap_timeline"),
ap_profile: application?.collections?.get("ap_profile"),
};
}
/**
* POST /admin/reader/mute — Mute an actor or keyword.
*/
export function muteController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url, keyword } = request.body;
if (!url && !keyword) {
return response.status(400).json({
success: false,
error: "Provide url or keyword to mute",
});
}
const collections = getModerationCollections(request);
await addMuted(collections, { url: url || undefined, keyword: keyword || undefined });
console.info(
`[ActivityPub] Muted ${url ? `actor: ${url}` : `keyword: ${keyword}`}`,
);
return response.json({
success: true,
type: "mute",
url: url || undefined,
keyword: keyword || undefined,
});
} catch (error) {
console.error("[ActivityPub] Mute failed:", error.message);
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unmute — Unmute an actor or keyword.
*/
export function unmuteController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url, keyword } = request.body;
if (!url && !keyword) {
return response.status(400).json({
success: false,
error: "Provide url or keyword to unmute",
});
}
const collections = getModerationCollections(request);
await removeMuted(collections, { url: url || undefined, keyword: keyword || undefined });
return response.json({
success: true,
type: "unmute",
url: url || undefined,
keyword: keyword || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/block — Block an actor (sends Block activity + removes timeline items).
*/
export function blockController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const collections = getModerationCollections(request);
// Store the block
await addBlocked(collections, url);
// Remove timeline items from this actor
if (collections.ap_timeline) {
await collections.ap_timeline.deleteMany({ "author.url": url });
}
// Send Block activity via federation
if (plugin._federation) {
try {
const { Block } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
documentLoader,
});
if (remoteActor) {
const block = new Block({
actor: ctx.getActorUri(handle),
object: new URL(url),
});
await ctx.sendActivity(
{ identifier: handle },
remoteActor,
block,
{ orderingKey: url },
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not send Block to ${url}: ${error.message}`,
);
}
}
console.info(`[ActivityPub] Blocked actor: ${url}`);
return response.json({
success: true,
type: "block",
url,
});
} catch (error) {
console.error("[ActivityPub] Block failed:", error.message);
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unblock — Unblock an actor (sends Undo(Block)).
*/
export function unblockController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const collections = getModerationCollections(request);
await removeBlocked(collections, url);
// Send Undo(Block) via federation
if (plugin._federation) {
try {
const { Block, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
documentLoader,
});
if (remoteActor) {
const block = new Block({
actor: ctx.getActorUri(handle),
object: new URL(url),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: block,
});
await ctx.sendActivity(
{ identifier: handle },
remoteActor,
undo,
{ orderingKey: url },
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not send Undo(Block) to ${url}: ${error.message}`,
);
}
}
console.info(`[ActivityPub] Unblocked actor: ${url}`);
return response.json({
success: true,
type: "unblock",
url,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/block-server — Block a server by hostname.
*/
export function blockServerController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { hostname, reason } = request.body;
if (!hostname) {
return response.status(400).json({
success: false,
error: "Missing hostname",
});
}
const collections = getModerationCollections(request);
await addBlockedServer(collections, hostname, reason);
console.info(`[ActivityPub] Blocked server: ${hostname}`);
return response.json({ success: true, type: "block-server", hostname });
} catch (error) {
console.error("[ActivityPub] Block server failed:", error.message);
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unblock-server — Unblock a server.
*/
export function unblockServerController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { hostname } = request.body;
if (!hostname) {
return response.status(400).json({
success: false,
error: "Missing hostname",
});
}
const collections = getModerationCollections(request);
await removeBlockedServer(collections, hostname);
console.info(`[ActivityPub] Unblocked server: ${hostname}`);
return response.json({ success: true, type: "unblock-server", hostname });
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* GET /admin/reader/moderation — View muted/blocked lists.
*/
export function moderationController(mountPath) {
return async (request, response, next) => {
try {
const collections = getModerationCollections(request);
const csrfToken = getToken(request.session);
const [muted, blocked, blockedServers, filterMode] = await Promise.all([
getAllMuted(collections),
getAllBlocked(collections),
getAllBlockedServers(collections),
getFilterMode(collections),
]);
const mutedActors = muted.filter((e) => e.url);
const mutedKeywords = muted.filter((e) => e.keyword);
response.render("activitypub-moderation", {
title: response.locals.__("activitypub.moderation.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
muted,
blocked,
blockedServers,
mutedActors,
mutedKeywords,
filterMode,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/moderation/filter-mode — Update filter mode.
*/
export function filterModeController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { mode } = request.body;
if (!mode || !["hide", "warn"].includes(mode)) {
return response.status(400).json({
success: false,
error: 'Mode must be "hide" or "warn"',
});
}
const collections = getModerationCollections(request);
await setFilterMode(collections, mode);
return response.json({ success: true, mode });
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}