Files
indiekit-endpoint-activitypub/lib/storage/moderation.js
Ricardo 12454749ad fix: comprehensive security, performance, and architecture audit fixes
27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low):

Security (Critical):
- Escape HTML in OAuth authorization page to prevent XSS (C1)
- Add CSRF protection to OAuth authorize flow (C2)
- Replace bypassable regex sanitizer with sanitize-html library (C3)
- Enforce OAuth scopes on all Mastodon API routes (C4)

Security (Medium/Low):
- Fix SSRF via DNS resolution before private IP check (M1)
- Add rate limiting to API, auth, and app registration endpoints (M2)
- Validate redirect_uri on POST /oauth/authorize (M4)
- Fix custom emoji URL injection with scheme validation + escaping (M5)
- Remove data: scheme from allowed image sources (L6)
- Add access token expiry (1hr) and refresh token rotation (90d) (M3)
- Hash client secrets before storage (L3)

Architecture:
- Extract batch-broadcast.js — shared delivery logic (H1a)
- Extract init-indexes.js — MongoDB index creation (H1b)
- Extract syndicator.js — syndication logic (H1c)
- Create federation-actions.js facade for controllers (M6)
- index.js reduced from 1810 to ~1169 lines (35%)

Performance:
- Cache moderation data with 30s TTL + write invalidation (H6)
- Increase inbox queue throughput to 10 items/sec (H5)
- Make account enrichment non-blocking with fire-and-forget (H4)
- Remove ephemeral getReplies/getLikes/getShares from ingest (M11)
- Fix LRU caches to use true LRU eviction (L1)
- Fix N+1 backfill queries with batch $in lookup (L2)

UI/UX:
- Split 3441-line reader.css into 15 feature-scoped files (H2)
- Extract inline Alpine.js interaction component (H3)
- Reduce sidebar navigation from 7 to 3 items (M7)
- Add ARIA live regions for dynamic content updates (M8)
- Extract shared CW/non-CW content partial (M9)
- Document form handling pattern convention (M10)
- Add accessible labels to functional emoji icons (L4)
- Convert profile editor to Alpine.js (L5)

Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md
Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
2026-03-25 07:41:20 +01:00

217 lines
6.5 KiB
JavaScript

/**
* Moderation storage operations (mute/block)
* @module storage/moderation
*/
import { invalidateModerationCache } from "../item-processing.js";
/**
* Add a muted URL or keyword
* @param {object} collections - MongoDB collections
* @param {object} data - Mute data
* @param {string} [data.url] - Actor URL to mute (mutually exclusive with keyword)
* @param {string} [data.keyword] - Keyword to mute (mutually exclusive with url)
* @returns {Promise<object>} Created mute entry
*/
export async function addMuted(collections, { url, keyword }) {
const { ap_muted } = collections;
if (!url && !keyword) {
throw new Error("Either url or keyword must be provided");
}
if (url && keyword) {
throw new Error("Cannot mute both url and keyword in same entry");
}
// Only include the field that's set — avoids null values that conflict
// with sparse unique indexes
const entry = { mutedAt: new Date().toISOString() };
if (url) entry.url = url;
if (keyword) entry.keyword = keyword;
// Upsert to avoid duplicates
const filter = url ? { url } : { keyword };
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_muted.findOne(filter);
}
/**
* Remove a muted URL or keyword
* @param {object} collections - MongoDB collections
* @param {object} data - Mute identifier
* @param {string} [data.url] - Actor URL to unmute
* @param {string} [data.keyword] - Keyword to unmute
* @returns {Promise<object>} Delete result
*/
export async function removeMuted(collections, { url, keyword }) {
const { ap_muted } = collections;
const filter = {};
if (url) {
filter.url = url;
} else if (keyword) {
filter.keyword = keyword;
} else {
throw new Error("Either url or keyword must be provided");
}
const result = await ap_muted.deleteOne(filter);
invalidateModerationCache();
return result;
}
/**
* Get all muted URLs
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of muted URLs
*/
export async function getMutedUrls(collections) {
const { ap_muted } = collections;
const entries = await ap_muted.find({ url: { $ne: null } }).toArray();
return entries.map((entry) => entry.url);
}
/**
* Get all muted keywords
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of muted keywords
*/
export async function getMutedKeywords(collections) {
const { ap_muted } = collections;
const entries = await ap_muted.find({ keyword: { $ne: null } }).toArray();
return entries.map((entry) => entry.keyword);
}
/**
* Check if a URL is muted
* @param {object} collections - MongoDB collections
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if muted
*/
export async function isUrlMuted(collections, url) {
const { ap_muted } = collections;
const entry = await ap_muted.findOne({ url });
return !!entry;
}
/**
* Check if content contains muted keywords
* @param {object} collections - MongoDB collections
* @param {string} content - Content text to check
* @returns {Promise<boolean>} True if contains muted keyword
*/
export async function containsMutedKeyword(collections, content) {
const keywords = await getMutedKeywords(collections);
const lowerContent = content.toLowerCase();
return keywords.some((keyword) => lowerContent.includes(keyword.toLowerCase()));
}
/**
* Add a blocked actor URL
* @param {object} collections - MongoDB collections
* @param {string} url - Actor URL to block
* @returns {Promise<object>} Created block entry
*/
export async function addBlocked(collections, url) {
const { ap_blocked } = collections;
const entry = {
url,
blockedAt: new Date().toISOString(),
};
// Upsert to avoid duplicates
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_blocked.findOne({ url });
}
/**
* Remove a blocked actor URL
* @param {object} collections - MongoDB collections
* @param {string} url - Actor URL to unblock
* @returns {Promise<object>} Delete result
*/
export async function removeBlocked(collections, url) {
const { ap_blocked } = collections;
const result = await ap_blocked.deleteOne({ url });
invalidateModerationCache();
return result;
}
/**
* Get all blocked URLs
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of blocked URLs
*/
export async function getBlockedUrls(collections) {
const { ap_blocked } = collections;
const entries = await ap_blocked.find({}).toArray();
return entries.map((entry) => entry.url);
}
/**
* Check if a URL is blocked
* @param {object} collections - MongoDB collections
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if blocked
*/
export async function isUrlBlocked(collections, url) {
const { ap_blocked } = collections;
const entry = await ap_blocked.findOne({ url });
return !!entry;
}
/**
* Get list of all muted entries (URLs and keywords)
* @param {object} collections - MongoDB collections
* @returns {Promise<object[]>} Array of mute entries
*/
export async function getAllMuted(collections) {
const { ap_muted } = collections;
return await ap_muted.find({}).toArray();
}
/**
* Get list of all blocked entries
* @param {object} collections - MongoDB collections
* @returns {Promise<object[]>} Array of block entries
*/
export async function getAllBlocked(collections) {
const { ap_blocked } = collections;
return await ap_blocked.find({}).toArray();
}
/**
* Get moderation filter mode ("hide" or "warn").
* "hide" removes filtered items from timeline entirely (default).
* "warn" shows them behind a content-warning toggle.
* Blocked actors are ALWAYS hidden regardless of mode.
* @param {object} collections - MongoDB collections (needs ap_profile)
* @returns {Promise<string>} "hide" or "warn"
*/
export async function getFilterMode(collections) {
const { ap_profile } = collections;
if (!ap_profile) return "hide";
const profile = await ap_profile.findOne({});
return profile?.moderationFilterMode || "hide";
}
/**
* Set moderation filter mode.
* @param {object} collections - MongoDB collections (needs ap_profile)
* @param {string} mode - "hide" or "warn"
*/
export async function setFilterMode(collections, mode) {
const { ap_profile } = collections;
if (!ap_profile) return;
const valid = mode === "warn" ? "warn" : "hide";
await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } });
invalidateModerationCache();
}