diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js index 8460530..49f3bae 100644 --- a/bot/src/discord/discordManager.js +++ b/bot/src/discord/discordManager.js @@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter') const scheduler = require('../scheduler/scheduler') const roleMenuHandler = require('./roleMenuHandler') const { handleGuildMemberAdd } = require('./guildMemberAdd') +const { handleGuildMemberRemove } = require('./guildMemberRemove') +const inviteTracker = require('./inviteTracker') const tempRoleSweeper = require('../roles/tempRoleSweeper') const inviteScheduler = require('../invites/inviteScheduler') @@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) { // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled - // in the Discord Developer Portal, see the Phase 1 setup notes. + // in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites + // (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle. client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildInvites, ], }) @@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) { await scheduler.start(client) tempRoleSweeper.start(client) inviteScheduler.start(client, guildId) + await inviteTracker.prime(client, guildId) status = 'connected' statusDetail = null lastConnectedAt = new Date() @@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) { client.on('messageCreate', messageFilter.handleMessageCreate) client.on('guildMemberAdd', handleGuildMemberAdd) + client.on('guildMemberRemove', handleGuildMemberRemove) + // Keep the invite-use cache fresh so guildMemberAdd can attribute joins. + client.on('inviteCreate', inviteTracker.onInviteCreate) + client.on('inviteDelete', inviteTracker.onInviteDelete) client.on('error', (err) => { status = 'error' diff --git a/bot/src/discord/guildMemberAdd.js b/bot/src/discord/guildMemberAdd.js index 9932176..e4fb5dd 100644 --- a/bot/src/discord/guildMemberAdd.js +++ b/bot/src/discord/guildMemberAdd.js @@ -1,11 +1,37 @@ -// Auto-role on join. Requires the Server Members privileged intent (already -// enabled in the Discord Developer Portal per the Phase 1 setup notes). +// Member join handling: record the join event (with best-effort invite +// attribution, Phase 6b) then apply the configured auto-role. Requires the +// Server Members privileged intent (already enabled per the Phase 1 setup notes) +// and, for invite attribution, the GuildInvites intent. const guildConfig = require('../model/guildConfig') +const memberEvents = require('../model/memberEvents') +const inviteTracker = require('./inviteTracker') const createLogger = require('../utils/logger') -const log = createLogger('autorole') +const log = createLogger('members') async function handleGuildMemberAdd(member) { + // Attribute the invite first (diffs the invite-use cache), then record the join. + // Both are best-effort — a failure here must never block the auto-role below. + let invite = { code: null, inviterId: null, inviterTag: null } + try { + invite = await inviteTracker.attribute(member) + } catch (err) { + log.warn('invite attribution threw', { userId: member.id, message: err.message }) + } + try { + await memberEvents.record({ + guildId: member.guild.id, + eventType: 'join', + discordUserId: member.id, + username: member.user?.tag, + inviteCode: invite.code, + inviterId: invite.inviterId, + inviterTag: invite.inviterTag, + }) + } catch (err) { + log.warn('member join record failed', { userId: member.id, message: err.message }) + } + try { const roleId = await guildConfig.getAutoRoleId(member.guild.id) if (!roleId) return diff --git a/bot/src/discord/guildMemberRemove.js b/bot/src/discord/guildMemberRemove.js new file mode 100644 index 0000000..840501e --- /dev/null +++ b/bot/src/discord/guildMemberRemove.js @@ -0,0 +1,23 @@ +// Member leave handling (Phase 6b): record a leave event for the dashboard's +// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't +// distinguish them on this event, and the mod-action (if any) is logged +// separately via mod_actions, so a leave row here is purely the lifecycle fact. +const memberEvents = require('../model/memberEvents') +const createLogger = require('../utils/logger') + +const log = createLogger('members') + +async function handleGuildMemberRemove(member) { + try { + await memberEvents.record({ + guildId: member.guild.id, + eventType: 'leave', + discordUserId: member.id, + username: member.user?.tag, + }) + } catch (err) { + log.warn('member leave record failed', { userId: member.id, message: err.message }) + } +} + +module.exports = { handleGuildMemberRemove } diff --git a/bot/src/discord/inviteTracker.js b/bot/src/discord/inviteTracker.js new file mode 100644 index 0000000..8886956 --- /dev/null +++ b/bot/src/discord/inviteTracker.js @@ -0,0 +1,74 @@ +// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you +// which invite a member used, so the standard approach is to keep a cache of +// each invite's use-count and, on guildMemberAdd, re-fetch and find the one +// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot +// already creates/deletes invites, so it has the permission). All calls are +// best-effort: any failure just yields a null attribution and the join is still +// recorded. Vanity-URL and bot-added joins are inherently unattributable. +const createLogger = require('../utils/logger') + +const log = createLogger('invites') + +// guildId -> Map +const cache = new Map() + +async function snapshot(guild) { + const map = new Map() + const invites = await guild.invites.fetch() + for (const inv of invites.values()) map.set(inv.code, inv.uses || 0) + return map +} + +// Populate the cache for a guild (call once the client is ready). +async function prime(client, guildId) { + try { + const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId)) + cache.set(guildId, await snapshot(guild)) + log.info('invite cache primed', { guildId, count: cache.get(guildId).size }) + } catch (err) { + log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message }) + } +} + +function onInviteCreate(invite) { + if (!invite.guild) return + const g = cache.get(invite.guild.id) || new Map() + g.set(invite.code, invite.uses || 0) + cache.set(invite.guild.id, g) +} + +function onInviteDelete(invite) { + if (!invite.guild) return + const g = cache.get(invite.guild.id) + if (g) g.delete(invite.code) +} + +// Diff current invite uses against the cached snapshot to find which invite the +// joining member used, then refresh the cache. Returns { code, inviterId, +// inviterTag } with nulls when it can't be determined. +async function attribute(member) { + const empty = { code: null, inviterId: null, inviterTag: null } + try { + const guild = member.guild + const before = cache.get(guild.id) || new Map() + const current = await guild.invites.fetch() + + let found = empty + for (const inv of current.values()) { + const prev = before.get(inv.code) || 0 + if ((inv.uses || 0) > prev && found === empty) { + found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null } + } + } + + const next = new Map() + for (const inv of current.values()) next.set(inv.code, inv.uses || 0) + cache.set(guild.id, next) + return found + } catch (err) { + log.warn('invite attribution failed', { message: err.message }) + return empty + } +} + +module.exports = { prime, onInviteCreate, onInviteDelete, attribute } diff --git a/bot/src/discord/messageFilter.js b/bot/src/discord/messageFilter.js index 56d3575..5c4a085 100644 --- a/bot/src/discord/messageFilter.js +++ b/bot/src/discord/messageFilter.js @@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize') const inviteFilter = require('../filter/inviteFilter') const spamFilter = require('../filter/spamFilter') const warnings = require('../model/warnings') +const filterHits = require('../model/filterHits') +const spamHits = require('../model/spamHits') const modLog = require('./modLog') const createLogger = require('../utils/logger') @@ -19,6 +21,48 @@ function botActor(client) { return { id: client.user.id, tag: client.user.tag } } +// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never +// break the moderation action it accompanies, so failures are swallowed+logged. +async function recordFilterHit(message, hitType, matched, actionTaken) { + try { + await filterHits.record({ + guildId: message.guildId, + hitType, + discordUserId: message.author.id, + username: message.author.tag, + channelId: message.channelId, + matched, + actionTaken, + }) + } catch (err) { + log.warn('filter hit record failed', { message: err.message }) + } +} + +async function recordSpamHit(message, spamType) { + try { + await spamHits.record({ + guildId: message.guildId, + spamType, + discordUserId: message.author.id, + username: message.author.tag, + channelId: message.channelId, + }) + } catch (err) { + log.warn('spam hit record failed', { message: err.message }) + } +} + +// Which spam rule tripped (for the spam_hits row). isRateLimited has a side +// effect (records this message's timestamp) so it must be evaluated first, and +// exactly once — mirroring the original OR-order. +function detectSpam(message) { + if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit' + if (spamFilter.isMassMention(message)) return 'mass_mention' + if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji' + return null +} + async function isBypassed(message, cache) { if (cache.allowChannels.has(message.channelId)) return true const memberRoles = message.member ? message.member.roles.cache : null @@ -62,8 +106,10 @@ async function handleMessageCreate(message) { const cache = await filterCache.getOrLoad(message.guildId) if (await isBypassed(message, cache)) return - if (await inviteFilter.containsForeignInvite(message)) { + const foreignCode = await inviteFilter.foreignInviteCode(message) + if (foreignCode) { await message.delete().catch(() => {}) + await recordFilterHit(message, 'invite', foreignCode, 'warn') await applyWarnAction(message, 'Posted a Discord invite link') return } @@ -71,17 +117,16 @@ async function handleMessageCreate(message) { const match = findMatch(message.content, cache.words) if (match) { await message.delete().catch(() => {}) + await recordFilterHit(message, 'word', match.word, match.severity) if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`) else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`) return } - if ( - spamFilter.isRateLimited(message.guildId, message.author.id) || - spamFilter.isMassMention(message) || - spamFilter.isMassEmoji(message.content) - ) { + const spamType = detectSpam(message) + if (spamType) { await message.delete().catch(() => {}) + await recordSpamHit(message, spamType) await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)') } } catch (err) { diff --git a/bot/src/filter/inviteFilter.js b/bot/src/filter/inviteFilter.js index 1805ea7..70c9925 100644 --- a/bot/src/filter/inviteFilter.js +++ b/bot/src/filter/inviteFilter.js @@ -4,20 +4,23 @@ // than silently letting an unresolvable link through. const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi -async function containsForeignInvite(message) { +// Returns the first foreign (or unresolvable) invite code found in the message, +// or null if the message contains no foreign invites. Returning the code (rather +// than a bare boolean) lets the caller record which invite was blocked. +async function foreignInviteCode(message) { const matches = [...message.content.matchAll(INVITE_REGEX)] - if (matches.length === 0) return false + if (matches.length === 0) return null for (const match of matches) { const code = match[1] try { const invite = await message.client.fetchInvite(code) - if (invite.guild?.id !== message.guildId) return true + if (invite.guild?.id !== message.guildId) return code } catch { - return true + return code } } - return false + return null } -module.exports = { containsForeignInvite } +module.exports = { foreignInviteCode } diff --git a/bot/src/model/filterHits.js b/bot/src/model/filterHits.js new file mode 100644 index 0000000..9100231 --- /dev/null +++ b/bot/src/model/filterHits.js @@ -0,0 +1,15 @@ +// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the +// word filter or foreign-invite filter deletes a message. mod_actions still +// records the resulting warn/mute separately. Schema: server/db/schema.sql +// (filter_hits). +const db = require('../db') + +async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) { + await db.query( + `INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken], + ) +} + +module.exports = { record } diff --git a/bot/src/model/memberEvents.js b/bot/src/model/memberEvents.js new file mode 100644 index 0000000..831c5fe --- /dev/null +++ b/bot/src/model/memberEvents.js @@ -0,0 +1,14 @@ +// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for +// the moderation dashboard's members feed + invite-usage view. Schema in +// server/db/schema.sql (member_events). +const db = require('../db') + +async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) { + await db.query( + `INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null], + ) +} + +module.exports = { record } diff --git a/bot/src/model/spamHits.js b/bot/src/model/spamHits.js new file mode 100644 index 0000000..e5fef14 --- /dev/null +++ b/bot/src/model/spamHits.js @@ -0,0 +1,14 @@ +// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the +// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the +// resulting warn separately. Schema: server/db/schema.sql (spam_hits). +const db = require('../db') + +async function record({ guildId, spamType, discordUserId, username, channelId }) { + await db.query( + `INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id) + VALUES (?, ?, ?, ?, ?)`, + [guildId, spamType, discordUserId, username || null, channelId || null], + ) +} + +module.exports = { record } diff --git a/client/src/App.jsx b/client/src/App.jsx index 2796c51..9deccb3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx' import { SiteProvider } from './contexts/SiteContext.jsx' import MaintenanceGate from './components/MaintenanceGate.jsx' import RequireAuth from './components/RequireAuth.jsx' +import RoleGate from './components/RoleGate.jsx' // Public import Portal from './routes/public/Portal.jsx' @@ -31,6 +32,8 @@ import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' +import Moderation from './routes/admin/views/Moderation.jsx' +import ModerationUser from './routes/admin/views/ModerationUser.jsx' export default function App() { return ( @@ -73,6 +76,17 @@ export default function App() { } /> } /> } /> + + + + } + > + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 1c87b58..91c8223 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -120,6 +120,52 @@ export const api = { updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), + // ----- moderation dashboard (admin + moderator) ----- + modSummary: () => req('/admin/moderation/stats/summary'), + modRecent: (params = {}) => { + const qs = new URLSearchParams() + if (params.type) qs.set('type', params.type) + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) + }, + modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), + modMembers: (params = {}) => { + const qs = new URLSearchParams() + if (params.type) qs.set('type', params.type) + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/members${s ? `?${s}` : ''}`) + }, + modFilterHits: (params = {}) => { + const qs = new URLSearchParams() + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`) + }, + modSpamHits: (params = {}) => { + const qs = new URLSearchParams() + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`) + }, + modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), + modUserActions: (discordId, params = {}) => { + const qs = new URLSearchParams() + if (params.type) qs.set('type', params.type) + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`) + }, + modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`), + addModNote: (discordId, data) => + req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }), + // ----- account security (self-service 2FA) ----- getAccount: () => req('/admin/account'), totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), diff --git a/client/src/components/RoleGate.jsx b/client/src/components/RoleGate.jsx new file mode 100644 index 0000000..bd4df0a --- /dev/null +++ b/client/src/components/RoleGate.jsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext.jsx' + +// Client-side role gate for admin sub-sections. Real enforcement is server-side +// (requireRole); this just keeps the UI honest — a user without one of `roles` +// is redirected rather than shown a page that will only 403 on every call. +export default function RoleGate({ roles, children, redirect = '/admin' }) { + const { user } = useAuth() + if (user && !roles.includes(user.role)) return + return children +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index ac0c82c..2a2e90b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx' +// `roles` (when present) restricts which roles see a nav item. Items without it +// are shown to admin/editor as before. Moderators are further confined to just +// their own section + account security (see the redirect effect below). const NAV = [ { to: '/admin', label: 'Dashboard', end: true }, { to: '/admin/posts', label: 'Posts' }, { to: '/admin/wiki', label: 'Wiki' }, { to: '/admin/hero', label: 'Hero Editor' }, + { to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] }, { to: '/admin/settings', label: 'Settings' }, { to: '/admin/activity', label: 'Activity' }, { to: '/admin/bot-activity', label: 'Bot Activity' }, @@ -23,6 +27,7 @@ const TITLES = { '/admin/posts': 'Posts', '/admin/wiki': 'Wiki Pages', '/admin/hero': 'Hero Editor', + '/admin/moderation': 'Moderation', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Bot Activity', @@ -48,11 +53,31 @@ export default function AdminLayout() { const { mode } = useSite() const navigate = useNavigate() const location = useLocation() - const title = TITLES[location.pathname] || 'Admin' + const title = + TITLES[location.pathname] || + (location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin') // The hero canvas editor needs room — let it use the full content width. const wide = location.pathname === '/admin/hero' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' + // Moderators only get the moderation section + their own account security. + const isModerator = user?.role === 'moderator' + const navItems = NAV.filter((n) => { + if (n.roles && !n.roles.includes(user?.role)) return false + if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account' + return true + }) + + // Confine a moderator who deep-links (or is redirected to the index) to a page + // outside their remit — the API would 403 anyway, so send them to their home. + useEffect(() => { + if (!isModerator) return + const p = location.pathname + if (!p.startsWith('/admin/moderation') && p !== '/admin/account') { + navigate('/admin/moderation', { replace: true }) + } + }, [isModerator, location.pathname, navigate]) + // Keep the admin out of search indexes (belt-and-suspenders with robots.txt). useEffect(() => { const meta = document.createElement('meta') @@ -94,7 +119,7 @@ export default function AdminLayout() {