Files
indiekit-endpoint-activitypub/lib/controllers/moderation.js
Ricardo 9a61145d97 feat: FEP-8fcf/fe34 compliance, custom emoji, manual follow approval (v2.13.0)
- FEP-8fcf: add syncCollection to Undo(Announce) sendActivity
- FEP-fe34: centralized lookupWithSecurity() helper with crossOrigin: "ignore" on all 23 lookupObject call sites
- Custom emoji: replaceCustomEmoji() renders :shortcode: as inline <img> in content and actor display names
- Manual follow approval: profile toggle, ap_pending_follows collection, approve/reject controllers with federation, pending tab on followers page, follow_request notification type
- Coverage audit updated to v2.12.x (overall ~70% → ~82%)

Confab-Link: http://localhost:8080/sessions/1f1e729b-0087-499e-a991-f36f46211fe4
2026-03-17 08:21:36 +01:00

353 lines
9.4 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";
/**
* 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_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.",
});
}
};
}
/**
* 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, filterMode] = await Promise.all([
getAllMuted(collections),
getAllBlocked(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,
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.",
});
}
};
}