Compare commits
18 Commits
5df943095d
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4c4c5235 | |||
| 3027bb0400 | |||
| b0c0d1fe9b | |||
| f2691959ff | |||
| 60d2121b83 | |||
| 20d3fbf594 | |||
| f8db61025b | |||
| 2067028070 | |||
| 03e62b56ad | |||
| 15cf8ea286 | |||
| 5f62eccdd8 | |||
| e8a54d9ff7 | |||
| 933206a1b8 | |||
| 1cfb79f5ae | |||
| 3ef84b41ef | |||
| bb5cc68c54 | |||
| 17c1eb07e8 | |||
| ad7aebb3ba |
@@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter')
|
|||||||
const scheduler = require('../scheduler/scheduler')
|
const scheduler = require('../scheduler/scheduler')
|
||||||
const roleMenuHandler = require('./roleMenuHandler')
|
const roleMenuHandler = require('./roleMenuHandler')
|
||||||
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||||
|
const { handleGuildMemberRemove } = require('./guildMemberRemove')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||||
const inviteScheduler = require('../invites/inviteScheduler')
|
const inviteScheduler = require('../invites/inviteScheduler')
|
||||||
|
|
||||||
@@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) {
|
|||||||
|
|
||||||
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||||
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
// (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({
|
client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildInvites,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) {
|
|||||||
await scheduler.start(client)
|
await scheduler.start(client)
|
||||||
tempRoleSweeper.start(client)
|
tempRoleSweeper.start(client)
|
||||||
inviteScheduler.start(client, guildId)
|
inviteScheduler.start(client, guildId)
|
||||||
|
await inviteTracker.prime(client, guildId)
|
||||||
status = 'connected'
|
status = 'connected'
|
||||||
statusDetail = null
|
statusDetail = null
|
||||||
lastConnectedAt = new Date()
|
lastConnectedAt = new Date()
|
||||||
@@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) {
|
|||||||
|
|
||||||
client.on('messageCreate', messageFilter.handleMessageCreate)
|
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||||
client.on('guildMemberAdd', handleGuildMemberAdd)
|
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) => {
|
client.on('error', (err) => {
|
||||||
status = 'error'
|
status = 'error'
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
// Auto-role on join. Requires the Server Members privileged intent (already
|
// Member join handling: record the join event (with best-effort invite
|
||||||
// enabled in the Discord Developer Portal per the Phase 1 setup notes).
|
// 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 guildConfig = require('../model/guildConfig')
|
||||||
|
const memberEvents = require('../model/memberEvents')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
const createLogger = require('../utils/logger')
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
const log = createLogger('autorole')
|
const log = createLogger('members')
|
||||||
|
|
||||||
async function handleGuildMemberAdd(member) {
|
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 {
|
try {
|
||||||
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||||
if (!roleId) return
|
if (!roleId) return
|
||||||
|
|||||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -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 }
|
||||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -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<inviteCode, uses>
|
||||||
|
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 }
|
||||||
@@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize')
|
|||||||
const inviteFilter = require('../filter/inviteFilter')
|
const inviteFilter = require('../filter/inviteFilter')
|
||||||
const spamFilter = require('../filter/spamFilter')
|
const spamFilter = require('../filter/spamFilter')
|
||||||
const warnings = require('../model/warnings')
|
const warnings = require('../model/warnings')
|
||||||
|
const filterHits = require('../model/filterHits')
|
||||||
|
const spamHits = require('../model/spamHits')
|
||||||
const modLog = require('./modLog')
|
const modLog = require('./modLog')
|
||||||
const createLogger = require('../utils/logger')
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
@@ -19,6 +21,48 @@ function botActor(client) {
|
|||||||
return { id: client.user.id, tag: client.user.tag }
|
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) {
|
async function isBypassed(message, cache) {
|
||||||
if (cache.allowChannels.has(message.channelId)) return true
|
if (cache.allowChannels.has(message.channelId)) return true
|
||||||
const memberRoles = message.member ? message.member.roles.cache : null
|
const memberRoles = message.member ? message.member.roles.cache : null
|
||||||
@@ -62,8 +106,10 @@ async function handleMessageCreate(message) {
|
|||||||
const cache = await filterCache.getOrLoad(message.guildId)
|
const cache = await filterCache.getOrLoad(message.guildId)
|
||||||
if (await isBypassed(message, cache)) return
|
if (await isBypassed(message, cache)) return
|
||||||
|
|
||||||
if (await inviteFilter.containsForeignInvite(message)) {
|
const foreignCode = await inviteFilter.foreignInviteCode(message)
|
||||||
|
if (foreignCode) {
|
||||||
await message.delete().catch(() => {})
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'invite', foreignCode, 'warn')
|
||||||
await applyWarnAction(message, 'Posted a Discord invite link')
|
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,17 +117,16 @@ async function handleMessageCreate(message) {
|
|||||||
const match = findMatch(message.content, cache.words)
|
const match = findMatch(message.content, cache.words)
|
||||||
if (match) {
|
if (match) {
|
||||||
await message.delete().catch(() => {})
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'word', match.word, match.severity)
|
||||||
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||||
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const spamType = detectSpam(message)
|
||||||
spamFilter.isRateLimited(message.guildId, message.author.id) ||
|
if (spamType) {
|
||||||
spamFilter.isMassMention(message) ||
|
|
||||||
spamFilter.isMassEmoji(message.content)
|
|
||||||
) {
|
|
||||||
await message.delete().catch(() => {})
|
await message.delete().catch(() => {})
|
||||||
|
await recordSpamHit(message, spamType)
|
||||||
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,20 +4,23 @@
|
|||||||
// than silently letting an unresolvable link through.
|
// than silently letting an unresolvable link through.
|
||||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
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)]
|
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||||
if (matches.length === 0) return false
|
if (matches.length === 0) return null
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const code = match[1]
|
const code = match[1]
|
||||||
try {
|
try {
|
||||||
const invite = await message.client.fetchInvite(code)
|
const invite = await message.client.fetchInvite(code)
|
||||||
if (invite.guild?.id !== message.guildId) return true
|
if (invite.guild?.id !== message.guildId) return code
|
||||||
} catch {
|
} catch {
|
||||||
return true
|
return code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { containsForeignInvite }
|
module.exports = { foreignInviteCode }
|
||||||
|
|||||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -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 }
|
||||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -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 }
|
||||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -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 }
|
||||||
@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
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 AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -73,6 +76,17 @@ export default function App() {
|
|||||||
<Route path="wiki" element={<WikiAdmin />} />
|
<Route path="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<Outlet />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Moderation />} />
|
||||||
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export const api = {
|
|||||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
loginTotp: (challenge, code) =>
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||||
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
authProviders: () => req('/auth/providers'),
|
authProviders: () => req('/auth/providers'),
|
||||||
@@ -117,6 +120,52 @@ export const api = {
|
|||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
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) -----
|
// ----- account security (self-service 2FA) -----
|
||||||
getAccount: () => req('/admin/account'),
|
getAccount: () => req('/admin/account'),
|
||||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
|
|||||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -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 <Navigate to={redirect} replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ export function AuthProvider({ children }) {
|
|||||||
return data.user
|
return data.user
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
||||||
|
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
||||||
|
const ssoLoginTotp = useCallback(async (code) => {
|
||||||
|
const data = await api.ssoLoginTotp(code)
|
||||||
|
setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
@@ -46,7 +54,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx'
|
|||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.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 = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{ to: '/admin', label: 'Dashboard', end: true },
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
{ to: '/admin/posts', label: 'Posts' },
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin/wiki', label: 'Wiki' },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||||
@@ -23,6 +27,7 @@ const TITLES = {
|
|||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Bot Activity',
|
'/admin/bot-activity': 'Bot Activity',
|
||||||
@@ -48,11 +53,31 @@ export default function AdminLayout() {
|
|||||||
const { mode } = useSite()
|
const { mode } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
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).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const meta = document.createElement('meta')
|
const meta = document.createElement('meta')
|
||||||
@@ -94,7 +119,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{NAV.map((n) => (
|
{navItems.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const honeypotStyle = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const { user, login, loginTotp } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
@@ -42,10 +42,12 @@ export default function AdminLogin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
// Two-factor step state.
|
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
||||||
|
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
||||||
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
const [challenge, setChallenge] = useState('')
|
const [challenge, setChallenge] = useState('')
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
// SSO providers to offer (empty if none configured) + any error the callback
|
// SSO providers to offer (empty if none configured) + any error the callback
|
||||||
// bounced us back with (?sso_error=...).
|
// bounced us back with (?sso_error=...).
|
||||||
@@ -57,6 +59,16 @@ export default function AdminLogin() {
|
|||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(dest, { replace: true })
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
|
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
|
||||||
|
// before it will issue a session. Jump straight to the code step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||||
// still works with password login and simply shows no provider buttons.
|
// still works with password login and simply shows no provider buttons.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,16 +113,25 @@ export default function AdminLogin() {
|
|||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await loginTotp(challenge, code)
|
if (ssoTotp) {
|
||||||
navigate(dest, { replace: true })
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
|
} else {
|
||||||
|
await loginTotp(challenge, code)
|
||||||
|
navigate(dest, { replace: true })
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
setError(
|
setError(
|
||||||
err.status === 401 && /expired/i.test(err.message)
|
expired
|
||||||
? 'Your verification session expired. Please sign in again.'
|
? 'Your verification session expired. Please sign in again.'
|
||||||
: 'Invalid verification code.',
|
: 'Invalid verification code.',
|
||||||
)
|
)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
330
client/src/routes/admin/views/Moderation.jsx
Normal file
330
client/src/routes/admin/views/Moderation.jsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { ago, dateTime } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
const WINDOWS = [
|
||||||
|
{ key: '24h', label: 'Last 24h' },
|
||||||
|
{ key: '7d', label: 'Last 7 days' },
|
||||||
|
{ key: '30d', label: 'Last 30 days' },
|
||||||
|
]
|
||||||
|
const TYPES = [
|
||||||
|
{ key: null, label: 'All' },
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
]
|
||||||
|
const MOD_TILES = [
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
]
|
||||||
|
// Second tile row → jumps the events panel to the matching stream.
|
||||||
|
const EVENT_TILES = [
|
||||||
|
{ key: 'joins', label: 'Joins', tab: 'members' },
|
||||||
|
{ key: 'leaves', label: 'Leaves', tab: 'members' },
|
||||||
|
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
|
||||||
|
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
|
||||||
|
]
|
||||||
|
const EVENT_TABS = [
|
||||||
|
{ key: 'members', label: 'Members' },
|
||||||
|
{ key: 'filter', label: 'Filter hits' },
|
||||||
|
{ key: 'spam', label: 'Spam hits' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Moderation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [win, setWin] = useState('24h')
|
||||||
|
const [typeFilter, setTypeFilter] = useState(null)
|
||||||
|
const [eventTab, setEventTab] = useState('members')
|
||||||
|
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() =>
|
||||||
|
Promise.all([
|
||||||
|
api.admin.modSummary(),
|
||||||
|
api.admin.modRecent({ limit: 100 }),
|
||||||
|
api.admin.modMembers({ limit: 50 }),
|
||||||
|
api.admin.modFilterHits({ limit: 50 }),
|
||||||
|
api.admin.modSpamHits({ limit: 50 }),
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load moderation data." />
|
||||||
|
|
||||||
|
const [summary, recent, members, filterHits, spamHits] = data
|
||||||
|
const counts = summary.windows?.[win] || {}
|
||||||
|
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
|
||||||
|
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<UserSearch onPick={goUser} />
|
||||||
|
|
||||||
|
{/* Window selector */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
|
||||||
|
{WINDOWS.map((w) => (
|
||||||
|
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
|
||||||
|
{w.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moderation-action tiles (click filters the recent-actions feed) */}
|
||||||
|
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
|
||||||
|
{MOD_TILES.map((t) => (
|
||||||
|
<Tile
|
||||||
|
key={t.key}
|
||||||
|
value={counts[t.key] ?? 0}
|
||||||
|
label={t.label}
|
||||||
|
active={typeFilter === t.key}
|
||||||
|
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event tiles (click jumps the events panel to that stream) */}
|
||||||
|
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
|
||||||
|
{EVENT_TILES.map((t) => (
|
||||||
|
<Tile
|
||||||
|
key={t.key}
|
||||||
|
value={counts[t.key] ?? 0}
|
||||||
|
label={t.label}
|
||||||
|
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
|
||||||
|
active={eventTab === t.tab}
|
||||||
|
onClick={() => setEventTab(t.tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
||||||
|
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Recent moderation actions */}
|
||||||
|
<div style={rowHead}>
|
||||||
|
<h2 className="display" style={h2}>Recent actions</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-flat" style={{ marginBottom: 30 }}>
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Action</th>
|
||||||
|
<th className="adm-th">Target</th>
|
||||||
|
<th className="adm-th">Staff</th>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{feed.length === 0 && (
|
||||||
|
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
|
||||||
|
)}
|
||||||
|
{feed.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
|
||||||
|
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event streams panel */}
|
||||||
|
<div style={rowHead}>
|
||||||
|
<h2 className="display" style={h2}>Events</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{EVENT_TABS.map((t) => (
|
||||||
|
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
|
||||||
|
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
|
||||||
|
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ value, label, sub, active, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: 20,
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'var(--panel-grad)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||||
|
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
|
||||||
|
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembersTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Event</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">Invite</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
|
||||||
|
{rows.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td dim">
|
||||||
|
{m.invite_code ? (
|
||||||
|
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Type</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">Matched</th>
|
||||||
|
<th className="adm-th">Action</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
|
||||||
|
{rows.map((f) => (
|
||||||
|
<tr key={f.id}>
|
||||||
|
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
|
||||||
|
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
|
||||||
|
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
|
||||||
|
|
||||||
|
function SpamTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Type</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
|
||||||
|
{rows.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User lookup: search by Discord id or a historical username snapshot.
|
||||||
|
function UserSearch({ onPick }) {
|
||||||
|
const [term, setTerm] = useState('')
|
||||||
|
const [results, setResults] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function run(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const q = term.trim()
|
||||||
|
if (!q) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
setResults(await api.admin.modSearch(q))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
|
||||||
|
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
|
||||||
|
</form>
|
||||||
|
{results && results.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match “{term}”.</p>
|
||||||
|
)}
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<div className="panel-flat" style={{ marginTop: 10 }}>
|
||||||
|
<table className="adm-table">
|
||||||
|
<tbody>
|
||||||
|
{results.map((r) => (
|
||||||
|
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
|
||||||
|
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
|
||||||
|
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
|
||||||
|
<td className="adm-td dim">last {ago(r.last_seen)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||||
|
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
|
||||||
|
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
|
||||||
|
const muted = { color: 'var(--muted)' }
|
||||||
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { dateTime, ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
const ACTION_TABS = [
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function fmtDuration(seconds) {
|
||||||
|
if (!seconds) return null
|
||||||
|
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||||
|
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModerationUser() {
|
||||||
|
const { discordId } = useParams()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
|
const [tab, setTab] = useState('warn')
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
|
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() =>
|
||||||
|
Promise.all([
|
||||||
|
api.admin.modUser(discordId),
|
||||||
|
api.admin.modUserActions(discordId, { limit: 200 }),
|
||||||
|
api.admin.modUserNotes(discordId),
|
||||||
|
]),
|
||||||
|
[discordId, tick],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load this user’s history." />
|
||||||
|
|
||||||
|
const [summary, actions, notes] = data
|
||||||
|
const counts = summary.counts || {}
|
||||||
|
const tabActions = actions.filter((a) => a.action_type === tab)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||||
|
← Back to moderation
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||||
|
{summary.tag || '(unknown user)'}
|
||||||
|
</span>
|
||||||
|
{summary.linked_account && (
|
||||||
|
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
|
||||||
|
{discordId}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
{ACTION_TABS.map((t) => (
|
||||||
|
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
|
||||||
|
))}
|
||||||
|
<Count label="Notes" value={summary.notes_count || 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
|
||||||
|
{ACTION_TABS.map((t) => (
|
||||||
|
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label} ({counts[t.key] || 0})
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
|
||||||
|
Notes ({summary.notes_count || 0})
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'notes' ? (
|
||||||
|
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||||
|
) : (
|
||||||
|
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Count({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||||
|
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '7px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
background: active ? 'var(--blue)' : 'transparent',
|
||||||
|
color: active ? 'var(--ink)' : 'var(--muted)',
|
||||||
|
borderColor: active ? 'var(--accent)' : 'var(--line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionTable({ rows, showDuration }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">Actor</th>
|
||||||
|
{showDuration && <th className="adm-th">Duration</th>}
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
|
||||||
|
Nothing here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{rows.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
{a.is_automated ? (
|
||||||
|
<span className="badge badge-auto">Automated</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
|
||||||
|
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [visibility, setVisibility] = useState('staff_only')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!body.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
|
||||||
|
setBody('')
|
||||||
|
setVisibility('staff_only')
|
||||||
|
onAdded()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not save the note.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
placeholder="Add a staff note about this user…"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
|
||||||
|
<option value="staff_only">Staff only</option>
|
||||||
|
{isAdmin && <option value="admin_only">Admin only</option>}
|
||||||
|
</select>
|
||||||
|
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Add note'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Note</th>
|
||||||
|
<th className="adm-th">Author</th>
|
||||||
|
<th className="adm-th">Visibility</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{notes.map((n) => (
|
||||||
|
<tr key={n.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
|
||||||
|
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
|
||||||
|
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
<select value={form.role} onChange={set('role')} className="select">
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="editor">editor</option>
|
<option value="editor">editor</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin and editor accounts
|
Manage admin, editor, and moderator accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td">
|
<td className="adm-td">
|
||||||
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -613,6 +613,29 @@ button[disabled] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.badge-moderator {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
/* Action-type badges for the moderation dashboard. */
|
||||||
|
.badge-ban {
|
||||||
|
background: rgba(217, 139, 132, 0.16);
|
||||||
|
color: #d98b84;
|
||||||
|
border: 1px solid rgba(217, 139, 132, 0.4);
|
||||||
|
}
|
||||||
|
.badge-kick,
|
||||||
|
.badge-mute,
|
||||||
|
.badge-warn {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
.badge-auto {
|
||||||
|
background: rgba(127, 153, 189, 0.14);
|
||||||
|
color: #9fb0c6;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
.link-accent {
|
.link-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
|
# Pin the bot's own listen port. Both services share env_file: .env, so
|
||||||
|
# without this the site's PORT=3000 leaks in and the bot binds 3000 instead
|
||||||
|
# of 4100 — then the server's BOT_INTERNAL_URL (http://bot:4100) can't reach
|
||||||
|
# it ("failed to fetch" in the admin panel). Must match that URL's port.
|
||||||
|
PORT: 4100
|
||||||
|
# Likewise override the log filename so the bot doesn't inherit the site's
|
||||||
|
# LOG_FILE and write into app.log — keep the bot's log distinct.
|
||||||
|
LOG_FILE: bot.log
|
||||||
# Internal config fetch goes to the app's UNPUBLISHED internal port (3001),
|
# Internal config fetch goes to the app's UNPUBLISHED internal port (3001),
|
||||||
# not the public 3000. Keep the port in sync with the app's INTERNAL_PORT.
|
# not the public 3000. Keep the port in sync with the app's INTERNAL_PORT.
|
||||||
SITE_INTERNAL_URL: http://app:3001/internal/bot-config
|
SITE_INTERNAL_URL: http://app:3001/internal/bot-config
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
username VARCHAR(32) NOT NULL UNIQUE,
|
username VARCHAR(32) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(72) NOT NULL,
|
password_hash VARCHAR(72) NOT NULL,
|
||||||
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
|
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
|
||||||
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
||||||
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
-- Any session token issued before this instant is rejected (see requireAuth).
|
||||||
|
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
|
||||||
|
tokens_valid_after DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login_at DATETIME NULL
|
last_login_at DATETIME NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -176,6 +179,22 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
INDEX idx_mrt_expires (expires_at)
|
INDEX idx_mrt_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
||||||
|
-- per session in createSession. A single logout adds this session's jti here;
|
||||||
|
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
||||||
|
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
|
||||||
|
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
|
||||||
|
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
|
||||||
|
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
|
||||||
|
CREATE TABLE IF NOT EXISTS revoked_sessions (
|
||||||
|
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
|
||||||
|
user_id INT NULL,
|
||||||
|
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
|
||||||
|
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_revoked_sessions_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
||||||
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
||||||
-- client secrets are, and is only ever decrypted server-side to push to the
|
-- client secrets are, and is only ever decrypted server-side to push to the
|
||||||
@@ -349,6 +368,83 @@ CREATE TABLE IF NOT EXISTS invite_log (
|
|||||||
INDEX idx_invite_log_guild (guild_id, created_at)
|
INDEX idx_invite_log_guild (guild_id, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
|
||||||
|
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
|
||||||
|
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
|
||||||
|
-- record which invite was used when the bot could attribute it (best-effort, see
|
||||||
|
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
|
||||||
|
-- These are member lifecycle events, not moderation actions, hence separate from
|
||||||
|
-- mod_actions.
|
||||||
|
CREATE TABLE IF NOT EXISTS member_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
guild_id VARCHAR(32) NOT NULL,
|
||||||
|
event_type ENUM('join','leave') NOT NULL,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
username VARCHAR(120) NULL,
|
||||||
|
invite_code VARCHAR(20) NULL,
|
||||||
|
inviter_id VARCHAR(32) NULL,
|
||||||
|
inviter_tag VARCHAR(120) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_member_events_guild (guild_id, created_at),
|
||||||
|
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
|
||||||
|
INDEX idx_member_events_invite (guild_id, invite_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Automated content-filter hits (Phase 6b): one row per message the word filter
|
||||||
|
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
|
||||||
|
-- records the resulting warn/mute) so the dashboard can show filter volume in
|
||||||
|
-- its own right. `matched` holds the offending word (word hits) or the blocked
|
||||||
|
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
|
||||||
|
-- (bot/src/discord/messageFilter.js).
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_hits (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
guild_id VARCHAR(32) NOT NULL,
|
||||||
|
hit_type ENUM('word','invite') NOT NULL,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
username VARCHAR(120) NULL,
|
||||||
|
channel_id VARCHAR(32) NULL,
|
||||||
|
matched VARCHAR(200) NULL,
|
||||||
|
action_taken ENUM('delete','warn','mute') NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_filter_hits_guild (guild_id, created_at),
|
||||||
|
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
|
||||||
|
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
|
||||||
|
-- warn; this records the detection itself for the dashboard's spam feed.
|
||||||
|
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
|
||||||
|
CREATE TABLE IF NOT EXISTS spam_hits (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
guild_id VARCHAR(32) NOT NULL,
|
||||||
|
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
username VARCHAR(120) NULL,
|
||||||
|
channel_id VARCHAR(32) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_spam_hits_guild (guild_id, created_at),
|
||||||
|
INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
|
||||||
|
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
|
||||||
|
-- and read only by the main site (moderation.controller), never by the bot.
|
||||||
|
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
|
||||||
|
-- notes attach to a Discord identity even when it has no linked site account.
|
||||||
|
-- Notes are never user-visible; admin_only notes are further restricted to the
|
||||||
|
-- admin role (moderators see staff_only only) — enforced in the query layer.
|
||||||
|
CREATE TABLE IF NOT EXISTS mod_notes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
author_user_id INT NULL,
|
||||||
|
author_tag VARCHAR(120) NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_mod_notes_user (discord_user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -357,6 +453,12 @@ CREATE TABLE IF NOT EXISTS invite_log (
|
|||||||
-- Opt-in TOTP two-factor columns for databases created before login hardening.
|
-- Opt-in TOTP two-factor columns for databases created before login hardening.
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
-- Session-revocation cutoff for databases created before token revocation landed.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
|
||||||
|
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
|
||||||
|
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
|
||||||
|
-- an idempotent no-op, so it is safe to run on every boot.
|
||||||
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
|
||||||
|
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
|
|||||||
const users = require('../model/users/users.model')
|
const users = require('../model/users/users.model')
|
||||||
const log = require('../utils/logger')('session')
|
const log = require('../utils/logger')('session')
|
||||||
|
|
||||||
|
// True if this session was issued at or before the user's tokens_valid_after
|
||||||
|
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
|
||||||
|
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
|
||||||
|
// minted in the same second as the bump must still be revoked (otherwise it would
|
||||||
|
// survive its full lifetime through that 1s alignment). The only cost is that a
|
||||||
|
// re-login within the same second as the change is rejected until the next second
|
||||||
|
// — a self-healing blip, and far preferable to leaving a stale token valid.
|
||||||
|
function isBeforeCutoff(session, tokensValidAfter) {
|
||||||
|
if (!tokensValidAfter || session.createdAt == null) return false
|
||||||
|
return session.createdAt <= new Date(tokensValidAfter).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort: if the request carries a valid session token, attach the decoded
|
// Best-effort: if the request carries a valid session token, attach the decoded
|
||||||
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
||||||
// anonymous requests simply pass through with req.session undefined.
|
// anonymous requests simply pass through with req.session undefined.
|
||||||
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const user = await users.getById(session.userId)
|
const user = await users.getById(session.userId)
|
||||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
||||||
|
|
||||||
|
// Revocation, enforced here (not in stateless token verification):
|
||||||
|
// 1. per-user cutoff — password change / "log out everywhere" bumps
|
||||||
|
// tokens_valid_after; any token issued before it is dead.
|
||||||
|
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
|
||||||
|
if (isBeforeCutoff(session, user.tokens_valid_after)) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
if (await sessionService.isSessionRevoked(session.sessionId)) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
req.user = user
|
req.user = user
|
||||||
req.session = session
|
req.session = session
|
||||||
req.authMethod = session.authMethod
|
req.authMethod = session.authMethod
|
||||||
|
|||||||
@@ -14,16 +14,20 @@
|
|||||||
// role,
|
// role,
|
||||||
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
||||||
// createdAt, // ms epoch the token was issued (JWT iat)
|
// createdAt, // ms epoch the token was issued (JWT iat)
|
||||||
|
// expiresAt, // ms epoch the token expires (JWT exp), or null
|
||||||
// lastSeenAt, // ms epoch this session was last validated
|
// lastSeenAt, // ms epoch this session was last validated
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
|
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
|
||||||
// server-side session store yet — these are documented hook points for a future
|
// denylist (revoked_sessions — single logout) and a per-user cutoff
|
||||||
// store (e.g. a denylist of jti, or mobile refresh-token records).
|
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
|
||||||
|
// consults both. The functions here are the seam the controllers call.
|
||||||
|
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
|
||||||
const token = require('./token')
|
const token = require('./token')
|
||||||
|
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
|
||||||
|
const users = require('../model/users/users.model')
|
||||||
const log = require('../utils/logger')('session')
|
const log = require('../utils/logger')('session')
|
||||||
|
|
||||||
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
||||||
@@ -32,10 +36,24 @@ const log = require('../utils/logger')('session')
|
|||||||
// authenticated without changing this module per provider.
|
// authenticated without changing this module per provider.
|
||||||
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
|
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
|
||||||
|
|
||||||
|
// The claim that positively marks a token as a real, full session. Every JWT in
|
||||||
|
// the app is signed with the same secret and is distinguished only by claims, so
|
||||||
|
// a session must be identified by what it *is* (typ === 'session'), never by the
|
||||||
|
// mere absence of some other marker. Only the session-minting paths below stamp
|
||||||
|
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
|
||||||
|
// not, so — even though they verify against the same secret — they can never be
|
||||||
|
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
|
||||||
|
const SESSION_TYP = 'session'
|
||||||
|
|
||||||
// Build a Session object from a decoded JWT payload. Returns null for anything
|
// Build a Session object from a decoded JWT payload. Returns null for anything
|
||||||
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
|
// that is not a full session. Validation is positively typed: a token qualifies
|
||||||
|
// only if it was explicitly minted as a session. As belt-and-suspenders we also
|
||||||
|
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
|
||||||
|
// SSO transaction), so a future minting path that forgets to omit those still
|
||||||
|
// can't produce an accepted session.
|
||||||
function sessionFromDecoded(decoded, now = Date.now()) {
|
function sessionFromDecoded(decoded, now = Date.now()) {
|
||||||
if (!decoded || decoded.stage) return null
|
if (!decoded || decoded.typ !== SESSION_TYP) return null
|
||||||
|
if (decoded.stage || decoded.kind) return null
|
||||||
return {
|
return {
|
||||||
sessionId: decoded.jti || null,
|
sessionId: decoded.jti || null,
|
||||||
userId: decoded.id,
|
userId: decoded.id,
|
||||||
@@ -43,6 +61,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
|
|||||||
role: decoded.role,
|
role: decoded.role,
|
||||||
authMethod: decoded.authMethod || 'local',
|
authMethod: decoded.authMethod || 'local',
|
||||||
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
||||||
|
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +75,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
|
|||||||
function createSession(user, authMethod = 'local') {
|
function createSession(user, authMethod = 'local') {
|
||||||
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
||||||
const sessionId = crypto.randomUUID()
|
const sessionId = crypto.randomUUID()
|
||||||
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
|
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
|
||||||
const session = sessionFromDecoded(token.verifyToken(raw))
|
const session = sessionFromDecoded(token.verifyToken(raw))
|
||||||
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
||||||
return { token: raw, session }
|
return { token: raw, session }
|
||||||
@@ -124,7 +143,7 @@ function mintMobileTokens(user, meta = {}, now = Date.now()) {
|
|||||||
const sessionId = crypto.randomUUID()
|
const sessionId = crypto.randomUUID()
|
||||||
const accessToken = token.signToken(
|
const accessToken = token.signToken(
|
||||||
user,
|
user,
|
||||||
{ authMethod: 'mobile', jti: sessionId },
|
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
|
||||||
{ expiresIn: MOBILE_ACCESS_TTL },
|
{ expiresIn: MOBILE_ACCESS_TTL },
|
||||||
)
|
)
|
||||||
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
||||||
@@ -179,23 +198,52 @@ function sessionMeta(req) {
|
|||||||
return { ip, userAgent, deviceHash }
|
return { ip, userAgent, deviceHash }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
|
// ── Revocation / invalidation ──────────────────────────────────────────────
|
||||||
// JWTs are stateless: there is no store to revoke against yet. These are the
|
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
|
||||||
// hook points a future session store (jti denylist, mobile refresh records)
|
// two server-side stores these functions write:
|
||||||
// will implement. They log and report success so callers can wire them in now.
|
// • revoked_sessions — a per-session jti denylist (single logout)
|
||||||
|
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
|
||||||
|
// A jti + its expiry (from the decoded token) are needed to denylist one session;
|
||||||
|
// invalidating all of a user's sessions only needs their id.
|
||||||
|
|
||||||
function revokeSession(sessionId) {
|
// Revoke a single session by its jti. Needs the token's expiry so the denylist
|
||||||
log.info('revokeSession (stub — no session store yet)', { sessionId })
|
// row can self-prune once the JWT would fail verification anyway. Idempotent.
|
||||||
|
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
|
||||||
|
if (!sessionId) {
|
||||||
|
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
|
||||||
|
// so the denylist row still outlives any token carrying this jti.
|
||||||
|
const exp = expiresAt || Date.now() + token.cookieMaxAge()
|
||||||
|
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
|
||||||
|
log.info('session revoked', { sessionId, userId })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateSession(sessionId) {
|
// Alias kept for callers that speak of "invalidating" one session.
|
||||||
log.info('invalidateSession (stub — no session store yet)', { sessionId })
|
async function invalidateSession(sessionId, opts) {
|
||||||
return true
|
return revokeSession(sessionId, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateAllUserSessions(userId) {
|
// Has this session (jti) been individually revoked? Used by requireAuth on every
|
||||||
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
|
// authenticated request. Broad "valid after" cutoffs are checked separately by
|
||||||
|
// the middleware against the fresh user row it already loads.
|
||||||
|
async function isSessionRevoked(sessionId) {
|
||||||
|
if (!sessionId) return false
|
||||||
|
return revokedSessions.isRevoked(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate every session a user holds (password change / log out everywhere)
|
||||||
|
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
|
||||||
|
// before now regardless of jti.
|
||||||
|
async function invalidateAllUserSessions(userId) {
|
||||||
|
if (!userId) {
|
||||||
|
log.warn('invalidateAllUserSessions called without a userId')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await users.invalidateSessions(userId)
|
||||||
|
log.info('all user sessions invalidated', { userId })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +257,7 @@ module.exports = {
|
|||||||
sessionMeta,
|
sessionMeta,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
invalidateSession,
|
invalidateSession,
|
||||||
|
isSessionRevoked,
|
||||||
invalidateAllUserSessions,
|
invalidateAllUserSessions,
|
||||||
// Mobile bearer sessions.
|
// Mobile bearer sessions.
|
||||||
createMobileSession,
|
createMobileSession,
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const token = require('./token')
|
|||||||
const TX_COOKIE = 'sso_tx'
|
const TX_COOKIE = 'sso_tx'
|
||||||
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
||||||
|
|
||||||
|
// Second leg of an SSO login for an account that has TOTP enabled. The callback
|
||||||
|
// authenticated the user with the IdP but must NOT bypass their second factor
|
||||||
|
// (see issue #31), so instead of minting a session it stages this signed,
|
||||||
|
// httpOnly cookie and routes the browser through the TOTP form — mirroring the
|
||||||
|
// local password→TOTP gate. TTL matches the local challenge window.
|
||||||
|
const TOTP_COOKIE = 'sso_totp'
|
||||||
|
const TOTP_TTL = '5m'
|
||||||
|
|
||||||
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
||||||
function randomUrlSafe(bytes = 32) {
|
function randomUrlSafe(bytes = 32) {
|
||||||
return crypto.randomBytes(bytes).toString('base64url')
|
return crypto.randomBytes(bytes).toString('base64url')
|
||||||
@@ -56,4 +64,38 @@ function verifyTx(txToken, stateNonce) {
|
|||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }
|
// Stage the pending second factor for an SSO login. Carries the context the
|
||||||
|
// callback already resolved (userId, provider, authMethod, returnTo) so that
|
||||||
|
// presenting a valid code alone finishes the login. It is deliberately NOT a
|
||||||
|
// session: `stage: 'totp'` makes session validation reject it (same marker the
|
||||||
|
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
|
||||||
|
// scopes it to the SSO completion endpoint.
|
||||||
|
function createTotpPending({ userId, provider, authMethod, returnTo }) {
|
||||||
|
return token.signToken(
|
||||||
|
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
|
||||||
|
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo },
|
||||||
|
{ expiresIn: TOTP_TTL },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a pending-TOTP cookie. Returns the payload
|
||||||
|
// ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind.
|
||||||
|
function verifyTotpPending(pendingToken) {
|
||||||
|
if (!pendingToken) return null
|
||||||
|
const decoded = token.verifyToken(pendingToken)
|
||||||
|
if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TX_COOKIE,
|
||||||
|
TX_TTL,
|
||||||
|
TOTP_COOKIE,
|
||||||
|
TOTP_TTL,
|
||||||
|
createTx,
|
||||||
|
verifyTx,
|
||||||
|
createTotpPending,
|
||||||
|
verifyTotpPending,
|
||||||
|
codeChallengeFor,
|
||||||
|
randomUrlSafe,
|
||||||
|
}
|
||||||
|
|||||||
51
server/src/model/modNotes/modNotes.db.js
Normal file
51
server/src/model/modNotes/modNotes.db.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
|
||||||
|
// Notes are never user-visible; admin_only notes are filtered out for non-admin
|
||||||
|
// callers at this layer via includeAdminOnly.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
|
||||||
|
return query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.discord_user_id = ? ${visClause}
|
||||||
|
ORDER BY n.id DESC`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[discordUserId, authorUserId, authorTag, body, visibility],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.id = ? LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return Number(rows[0].c)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, insert, getById, countForUser }
|
||||||
18
server/src/model/modNotes/modNotes.model.js
Normal file
18
server/src/model/modNotes/modNotes.model.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const modNotesDb = require('./modNotes.db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
return modNotesDb.listForUser(discordId, { includeAdminOnly })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
|
||||||
|
const id = await modNotesDb.insert({
|
||||||
|
discordUserId,
|
||||||
|
authorUserId: author ? author.id : null,
|
||||||
|
authorTag: author ? author.username : null,
|
||||||
|
body,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
|
return modNotesDb.getById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, add }
|
||||||
188
server/src/model/moderation/moderation.db.js
Normal file
188
server/src/model/moderation/moderation.db.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Read-only access to the bot-owned moderation tables (mod_actions) for the
|
||||||
|
// admin moderation dashboard (Phase 6). These tables are normally owned by the
|
||||||
|
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
|
||||||
|
// in the same physical database, so the site reads them directly through the
|
||||||
|
// shared pool rather than round-tripping the bot over the internal API. This
|
||||||
|
// module NEVER writes them; all writes still belong to the bot.
|
||||||
|
//
|
||||||
|
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
|
||||||
|
// command also mirrors into `warnings`, so counting mod_actions avoids double
|
||||||
|
// counting). Accounts are correlated to Discord ids via user_identities
|
||||||
|
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const TYPES = ['ban', 'kick', 'mute', 'warn']
|
||||||
|
|
||||||
|
// Per-type counts across three nested windows in a single scan. Boolean
|
||||||
|
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
|
||||||
|
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
|
||||||
|
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type,
|
||||||
|
SUM(created_at >= ?) AS d1,
|
||||||
|
SUM(created_at >= ?) AS d7,
|
||||||
|
SUM(created_at >= ?) AS d30
|
||||||
|
FROM mod_actions
|
||||||
|
WHERE created_at >= ?
|
||||||
|
GROUP BY action_type`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_SELECT = `
|
||||||
|
SELECT ma.id, ma.guild_id, ma.action_type,
|
||||||
|
ma.target_user_id, ma.target_tag,
|
||||||
|
ma.staff_user_id, ma.staff_tag,
|
||||||
|
ma.reason, ma.duration_seconds, ma.created_at,
|
||||||
|
ui.user_id AS target_site_user_id,
|
||||||
|
u.username AS target_site_username
|
||||||
|
FROM mod_actions ma
|
||||||
|
LEFT JOIN user_identities ui
|
||||||
|
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
|
||||||
|
LEFT JOIN users u ON u.id = ui.user_id`
|
||||||
|
|
||||||
|
// Most-recent-first action feed, optionally filtered by type. limit/offset
|
||||||
|
// pagination matching the activity-log convention.
|
||||||
|
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type ? 'WHERE ma.action_type = ?' : ''
|
||||||
|
const params = type ? [type, limit, offset] : [limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full action history for one Discord user, optionally filtered by type.
|
||||||
|
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type
|
||||||
|
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
|
||||||
|
: 'WHERE ma.target_user_id = ?'
|
||||||
|
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-time per-type counts for one user.
|
||||||
|
async function userCounts(discordId) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type, COUNT(*) AS c FROM mod_actions
|
||||||
|
WHERE target_user_id = ? GROUP BY action_type`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
|
||||||
|
async function latestTag(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] ? rows[0].target_tag : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked site account for a Discord id, if any (via user_identities).
|
||||||
|
async function linkedAccount(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT u.id, u.username, u.role
|
||||||
|
FROM user_identities ui
|
||||||
|
JOIN users u ON u.id = ui.user_id
|
||||||
|
WHERE ui.provider = 'discord' AND ui.subject = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
|
||||||
|
|
||||||
|
// Join/leave counts per window (grouped by event_type).
|
||||||
|
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
return query(
|
||||||
|
`SELECT event_type,
|
||||||
|
SUM(created_at >= ?) AS d1,
|
||||||
|
SUM(created_at >= ?) AS d7,
|
||||||
|
SUM(created_at >= ?) AS d30
|
||||||
|
FROM member_events
|
||||||
|
WHERE created_at >= ?
|
||||||
|
GROUP BY event_type`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributed-invite join counts per window (joins whose invite we identified).
|
||||||
|
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
|
||||||
|
FROM member_events
|
||||||
|
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row-count per window for a simple event table. `table` is an internal literal
|
||||||
|
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
|
||||||
|
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
|
||||||
|
FROM ${table} WHERE created_at >= ?`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type ? 'WHERE event_type = ?' : ''
|
||||||
|
const params = type ? [type, limit, offset] : [limit, offset]
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
|
||||||
|
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
|
||||||
|
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
|
||||||
|
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
|
||||||
|
// by prefix, returning the most recently seen distinct targets. Powers the
|
||||||
|
// dashboard search box (usernames drift, so we search historical snapshots too).
|
||||||
|
async function searchTargets(term, { limit = 20 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
|
||||||
|
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
|
||||||
|
FROM mod_actions ma
|
||||||
|
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
|
||||||
|
GROUP BY ma.target_user_id
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[term, `${term}%`, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TYPES,
|
||||||
|
countsByWindow,
|
||||||
|
recentActions,
|
||||||
|
userActions,
|
||||||
|
userCounts,
|
||||||
|
latestTag,
|
||||||
|
linkedAccount,
|
||||||
|
searchTargets,
|
||||||
|
// Phase 6b
|
||||||
|
memberCountsByWindow,
|
||||||
|
inviteJoinCountsByWindow,
|
||||||
|
tableCountsByWindow,
|
||||||
|
recentMemberEvents,
|
||||||
|
recentFilterHits,
|
||||||
|
recentSpamHits,
|
||||||
|
}
|
||||||
105
server/src/model/moderation/moderation.model.js
Normal file
105
server/src/model/moderation/moderation.model.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Business logic for the moderation dashboard: reshapes the raw mod_actions
|
||||||
|
// reads into the shapes the admin UI consumes, and annotates each action with
|
||||||
|
// whether it was an automated (bot) action. For a Discord bot the application_id
|
||||||
|
// IS the bot's user id, and the filter/spam pipeline records automated actions
|
||||||
|
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
|
||||||
|
// staff_user_id === bot_config.application_id reliably flags automated actions
|
||||||
|
// without needing new columns on mod_actions.
|
||||||
|
const moderationDb = require('./moderation.db')
|
||||||
|
const botConfigDb = require('../botConfig/botConfig.db')
|
||||||
|
const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure')
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const WINDOW_KEYS = ['24h', '7d', '30d']
|
||||||
|
|
||||||
|
async function botApplicationId() {
|
||||||
|
try {
|
||||||
|
const cfg = await botConfigDb.get()
|
||||||
|
return cfg ? cfg.application_id : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers
|
||||||
|
// moderation actions (mod_actions) plus the Phase 6b event streams: member
|
||||||
|
// joins/leaves, attributed invite joins, and filter/spam hits.
|
||||||
|
async function summary() {
|
||||||
|
const now = Date.now()
|
||||||
|
const cutoffs = {
|
||||||
|
cutoff24h: new Date(now - DAY_MS),
|
||||||
|
cutoff7d: new Date(now - 7 * DAY_MS),
|
||||||
|
cutoff30d: new Date(now - 30 * DAY_MS),
|
||||||
|
}
|
||||||
|
|
||||||
|
const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
|
||||||
|
moderationDb.countsByWindow(cutoffs),
|
||||||
|
moderationDb.memberCountsByWindow(cutoffs),
|
||||||
|
moderationDb.inviteJoinCountsByWindow(cutoffs),
|
||||||
|
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
|
||||||
|
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
|
||||||
|
])
|
||||||
|
|
||||||
|
const windows = reshapeWindows(modRows).windows
|
||||||
|
const joinRow = memberRows.find((r) => r.event_type === 'join')
|
||||||
|
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
|
||||||
|
for (const w of WINDOW_KEYS) {
|
||||||
|
windows[w].joins = windowValue(joinRow, w)
|
||||||
|
windows[w].leaves = windowValue(leaveRow, w)
|
||||||
|
windows[w].invite_joins = windowValue(inviteRow, w)
|
||||||
|
windows[w].filter_hits = windowValue(filterRow, w)
|
||||||
|
windows[w].spam_hits = windowValue(spamRow, w)
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent event feeds for the overview's secondary panel (Phase 6b).
|
||||||
|
async function members(opts) {
|
||||||
|
return moderationDb.recentMemberEvents(opts)
|
||||||
|
}
|
||||||
|
async function filterHits(opts) {
|
||||||
|
return moderationDb.recentFilterHits(opts)
|
||||||
|
}
|
||||||
|
async function spamHits(opts) {
|
||||||
|
return moderationDb.recentSpamHits(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recent(opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.recentActions(opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userActions(discordId, opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.userActions(discordId, opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header data for the per-user history page: latest known tag, linked site
|
||||||
|
// account (if any), and all-time counts per action type.
|
||||||
|
async function userSummary(discordId) {
|
||||||
|
const [countRows, tag, linked] = await Promise.all([
|
||||||
|
moderationDb.userCounts(discordId),
|
||||||
|
moderationDb.latestTag(discordId),
|
||||||
|
moderationDb.linkedAccount(discordId),
|
||||||
|
])
|
||||||
|
const counts = zeroCounts()
|
||||||
|
let total = 0
|
||||||
|
for (const row of countRows) {
|
||||||
|
const c = Number(row.c) || 0
|
||||||
|
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
|
||||||
|
total += c
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
discord_user_id: discordId,
|
||||||
|
tag,
|
||||||
|
linked_account: linked,
|
||||||
|
counts,
|
||||||
|
total_actions: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(term, opts) {
|
||||||
|
return moderationDb.searchTargets(term, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }
|
||||||
47
server/src/model/moderation/moderation.pure.js
Normal file
47
server/src/model/moderation/moderation.pure.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
|
||||||
|
// free of any DB (or other side-effecting) imports so they can be unit-tested
|
||||||
|
// without opening a database pool. moderation.model re-exports these.
|
||||||
|
|
||||||
|
function zeroCounts() {
|
||||||
|
return { ban: 0, kick: 0, mute: 0, warn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag each action as automated (staff is the bot) and fold the joined
|
||||||
|
// user_identities columns into a linked_account object. The string coercion
|
||||||
|
// matters — snowflakes can arrive as number or string from different columns.
|
||||||
|
function annotate(rows, appId) {
|
||||||
|
return rows.map((r) => {
|
||||||
|
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
is_automated: isAutomated,
|
||||||
|
linked_account: r.target_site_user_id
|
||||||
|
? { id: r.target_site_user_id, username: r.target_site_username }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
|
||||||
|
// shape the dashboard tiles consume, zero-filling any type with no rows.
|
||||||
|
function reshapeWindows(rows) {
|
||||||
|
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
|
||||||
|
for (const row of rows) {
|
||||||
|
const t = row.action_type
|
||||||
|
if (windows['24h'][t] === undefined) continue
|
||||||
|
windows['24h'][t] = Number(row.d1) || 0
|
||||||
|
windows['7d'][t] = Number(row.d7) || 0
|
||||||
|
windows['30d'][t] = Number(row.d30) || 0
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the count for one window key ('24h'|'7d'|'30d') out of a
|
||||||
|
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
|
||||||
|
function windowValue(row, key) {
|
||||||
|
if (!row) return 0
|
||||||
|
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
|
||||||
|
return Number(col) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }
|
||||||
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
|
||||||
|
// and carry the token's own expiry so they can be pruned once the underlying JWT
|
||||||
|
// would fail verification anyway. This is the web/cookie analogue of
|
||||||
|
// mobile_refresh_tokens (opaque, DB-stored, revocable).
|
||||||
|
|
||||||
|
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
|
||||||
|
// session a harmless no-op (the PK already exists). Returns rows changed.
|
||||||
|
async function add({ jti, userId = null, expiresAt }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
[jti, userId, new Date(expiresAt)],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if this jti is on the denylist and not yet past its stored expiry. Past
|
||||||
|
// expiry the token itself is already invalid, so a lingering row need not match.
|
||||||
|
async function isRevoked(jti) {
|
||||||
|
if (!jti) return false
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
|
||||||
|
[jti],
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
|
||||||
|
async function pruneExpired() {
|
||||||
|
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
add,
|
||||||
|
isRevoked,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
|
||||||
|
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
|
||||||
|
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
|
||||||
|
// checks isRevoked on every authenticated request. Broad invalidation
|
||||||
|
// ("everywhere" / password change) does NOT live here — it bumps
|
||||||
|
// users.tokens_valid_after instead.
|
||||||
|
|
||||||
|
const db = require('./revokedSessions.db')
|
||||||
|
|
||||||
|
// Add a session's jti to the denylist (single-session logout). Idempotent.
|
||||||
|
async function revoke({ jti, userId, expiresAt }) {
|
||||||
|
return db.add({ jti, userId, expiresAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if the given jti has been revoked (and its token hasn't expired yet).
|
||||||
|
async function isRevoked(jti) {
|
||||||
|
return db.isRevoked(jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop denylist rows whose token has already expired.
|
||||||
|
async function pruneExpired() {
|
||||||
|
return db.pruneExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
revoke,
|
||||||
|
isRevoked,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@ async function touchLastLogin(id) {
|
|||||||
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
|
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the "tokens valid after" cutoff to now, invalidating every session token
|
||||||
|
// issued before this instant (password change / log out everywhere). requireAuth
|
||||||
|
// compares each session's issued-at against this column.
|
||||||
|
async function bumpTokensValidAfter(id) {
|
||||||
|
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
|
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
|
||||||
// so a secret is never trusted until the user has confirmed one code.
|
// so a secret is never trusted until the user has confirmed one code.
|
||||||
async function setTotpSecret(id, secret) {
|
async function setTotpSecret(id, secret) {
|
||||||
@@ -78,6 +85,7 @@ module.exports = {
|
|||||||
countUsers,
|
countUsers,
|
||||||
countAdmins,
|
countAdmins,
|
||||||
touchLastLogin,
|
touchLastLogin,
|
||||||
|
bumpTokensValidAfter,
|
||||||
setTotpSecret,
|
setTotpSecret,
|
||||||
enableTotp,
|
enableTotp,
|
||||||
disableTotp,
|
disableTotp,
|
||||||
|
|||||||
@@ -58,9 +58,18 @@ async function update(id, { username, password, role }) {
|
|||||||
if (role !== undefined) fields.role = role
|
if (role !== undefined) fields.role = role
|
||||||
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||||
await usersDb.updateUser(id, fields)
|
await usersDb.updateUser(id, fields)
|
||||||
|
// A password change must revoke existing sessions ("change password to log
|
||||||
|
// everyone out"), so bump the cutoff whenever the hash was rotated.
|
||||||
|
if (password) await usersDb.bumpTokensValidAfter(id)
|
||||||
return getById(id)
|
return getById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate every session token this user currently holds ("log out everywhere")
|
||||||
|
// by advancing their tokens_valid_after cutoff to now.
|
||||||
|
async function invalidateSessions(id) {
|
||||||
|
return usersDb.bumpTokensValidAfter(id)
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id) {
|
||||||
return usersDb.deleteUser(id)
|
return usersDb.deleteUser(id)
|
||||||
}
|
}
|
||||||
@@ -85,6 +94,7 @@ module.exports = {
|
|||||||
validatePassword,
|
validatePassword,
|
||||||
list,
|
list,
|
||||||
update,
|
update,
|
||||||
|
invalidateSessions,
|
||||||
remove,
|
remove,
|
||||||
count,
|
count,
|
||||||
countAdmins,
|
countAdmins,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const account = require('./account.controller')
|
|||||||
const botActivity = require('./botActivity.controller')
|
const botActivity = require('./botActivity.controller')
|
||||||
const authProviders = require('./authProviders.controller')
|
const authProviders = require('./authProviders.controller')
|
||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
|
const moderation = require('./moderation.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -23,6 +24,11 @@ adminRouter.use(noindex, isLoggedIn)
|
|||||||
// management, site mode, and settings are restricted to the admin role.
|
// management, site mode, and settings are restricted to the admin role.
|
||||||
const adminOnly = requireRole('admin')
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
// Moderation-dashboard gate. Moderators get the moderation views; admins can do
|
||||||
|
// everything a moderator can. Sensitive writes (admin_only notes) add an extra
|
||||||
|
// admin check inside the controller.
|
||||||
|
const modAccess = requireRole('admin', 'moderator')
|
||||||
|
|
||||||
// ── Account security (self-service, any logged-in role) ───────────────
|
// ── Account security (self-service, any logged-in role) ───────────────
|
||||||
// Not behind adminOnly: an editor manages their own 2FA too.
|
// Not behind adminOnly: an editor manages their own 2FA too.
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
@@ -30,7 +36,7 @@ adminRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Get the current account (self)'
|
// #swagger.summary = 'Get the current account (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
account.getAccount,
|
account.getAccount,
|
||||||
)
|
)
|
||||||
@@ -39,7 +45,7 @@ adminRouter.post(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { type: "object", properties: { otpauth_url: { type: "string" }, qr: { type: "string" } } } } } } */
|
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
account.totpSetup,
|
account.totpSetup,
|
||||||
@@ -50,7 +56,7 @@ adminRouter.post(
|
|||||||
// #swagger.summary = 'Enable 2FA by confirming a code'
|
// #swagger.summary = 'Enable 2FA by confirming a code'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -64,7 +70,7 @@ adminRouter.post(
|
|||||||
// #swagger.summary = 'Disable 2FA by confirming a code'
|
// #swagger.summary = 'Disable 2FA by confirming a code'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
@@ -78,7 +84,7 @@ adminRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'List linked SSO identities (self)'
|
// #swagger.summary = 'List linked SSO identities (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { provider: { type: "string" }, email: { type: "string" } } } } } } } */
|
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
account.listIdentities,
|
account.listIdentities,
|
||||||
)
|
)
|
||||||
@@ -88,7 +94,7 @@ adminRouter.delete(
|
|||||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('provider').matches(/^[a-z0-9-]+$/),
|
param('provider').matches(/^[a-z0-9-]+$/),
|
||||||
@@ -136,7 +142,7 @@ adminRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Dashboard']
|
// #swagger.tags = ['Admin · Dashboard']
|
||||||
// #swagger.summary = 'Dashboard summary counts'
|
// #swagger.summary = 'Dashboard summary counts'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'Summary counts (posts, wiki, users, site mode)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
ctrl.dashboard,
|
ctrl.dashboard,
|
||||||
)
|
)
|
||||||
@@ -147,7 +153,7 @@ adminRouter.put(
|
|||||||
// #swagger.description = 'Switch the site between live and maintenance.'
|
// #swagger.description = 'Switch the site between live and maintenance.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "maintenance" } } } } } } */
|
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -257,7 +263,7 @@ adminRouter.delete(
|
|||||||
// #swagger.summary = 'Delete a post'
|
// #swagger.summary = 'Delete a post'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
@@ -318,7 +324,7 @@ adminRouter.delete(
|
|||||||
// #swagger.summary = 'Delete a wiki category'
|
// #swagger.summary = 'Delete a wiki category'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
@@ -457,7 +463,7 @@ adminRouter.delete(
|
|||||||
// #swagger.summary = 'Delete a wiki page'
|
// #swagger.summary = 'Delete a wiki page'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
ctrl.deleteWiki,
|
ctrl.deleteWiki,
|
||||||
@@ -521,7 +527,7 @@ adminRouter.post(
|
|||||||
// #swagger.summary = 'Emergency unban an IP (admin only)'
|
// #swagger.summary = 'Emergency unban an IP (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
/* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -636,7 +642,7 @@ adminRouter.delete(
|
|||||||
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -647,6 +653,91 @@ adminRouter.delete(
|
|||||||
authProviders.remove,
|
authProviders.remove,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
||||||
|
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
||||||
|
// sub-path is gated for the moderator role (admins included).
|
||||||
|
adminRouter.use('/moderation', modAccess)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/stats/summary',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getSummary,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/recent',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getRecent,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/search',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.search,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/members',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getMembers,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/filter-hits',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Recent automated content-filter hits'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getFilterHits,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/spam-hits',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Recent automated spam-detection hits'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getSpamHits,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUser,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId/actions',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Full moderation action history for a user'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUserActions,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId/notes',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUserNotes,
|
||||||
|
)
|
||||||
|
adminRouter.post(
|
||||||
|
'/moderation/user/:discordId/notes',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||||
|
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||||||
|
validate,
|
||||||
|
moderation.addUserNote,
|
||||||
|
)
|
||||||
|
|
||||||
// ── User management (admin only) ──────────────────────────────────────
|
// ── User management (admin only) ──────────────────────────────────────
|
||||||
adminRouter.use('/users', adminOnly)
|
adminRouter.use('/users', adminOnly)
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
@@ -672,7 +763,7 @@ adminRouter.post(
|
|||||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||||
validate,
|
validate,
|
||||||
ctrl.createUser,
|
ctrl.createUser,
|
||||||
)
|
)
|
||||||
@@ -692,7 +783,7 @@ adminRouter.put(
|
|||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||||
validate,
|
validate,
|
||||||
ctrl.updateUser,
|
ctrl.updateUser,
|
||||||
)
|
)
|
||||||
@@ -702,7 +793,7 @@ adminRouter.delete(
|
|||||||
// #swagger.summary = 'Delete a user (admin only)'
|
// #swagger.summary = 'Delete a user (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
|||||||
170
server/src/router/v1/admin/moderation.controller.js
Normal file
170
server/src/router/v1/admin/moderation.controller.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
|
||||||
|
// mod_actions log plus server-owned staff notes. Mounted behind the
|
||||||
|
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is
|
||||||
|
// adding a staff note; admin_only notes are further restricted to the admin role.
|
||||||
|
const moderation = require('../../../model/moderation/moderation.model')
|
||||||
|
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||||
|
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('moderation')
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
||||||
|
const MAX_LIMIT = 200
|
||||||
|
const DEFAULT_LIMIT = 50
|
||||||
|
|
||||||
|
// Parse ?limit/&offset the same way the activity log does: numeric, capped.
|
||||||
|
function pageParams(req) {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || DEFAULT_LIMIT, MAX_LIMIT)
|
||||||
|
const offset = Number(req.query.offset) || 0
|
||||||
|
return { limit, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional ?type filter — ignored unless it is a known action type.
|
||||||
|
function typeParam(req) {
|
||||||
|
const t = req.query.type
|
||||||
|
return VALID_TYPES.has(t) ? t : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(req) {
|
||||||
|
return req.user && req.user.role === 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSummary(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await moderation.summary())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('summary failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecent(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(await moderation.recent({ type: typeParam(req), limit, offset }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('recent failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(req, res) {
|
||||||
|
try {
|
||||||
|
const term = (req.query.q || '').trim()
|
||||||
|
if (!term) return res.json([])
|
||||||
|
return res.json(await moderation.search(term, { limit: 20 }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('search failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 6b event feeds ──────────────────────────────────────────────
|
||||||
|
const MEMBER_TYPES = new Set(['join', 'leave'])
|
||||||
|
|
||||||
|
async function getMembers(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null
|
||||||
|
return res.json(await moderation.members({ type: t, limit, offset }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('members failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFilterHits(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(await moderation.filterHits({ limit, offset }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('filterHits failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSpamHits(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(await moderation.spamHits({ limit, offset }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('spamHits failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(req, res) {
|
||||||
|
try {
|
||||||
|
const summary = await moderation.userSummary(req.params.discordId)
|
||||||
|
const notesCount = await modNotesDb.countForUser(req.params.discordId, {
|
||||||
|
includeAdminOnly: isAdmin(req),
|
||||||
|
})
|
||||||
|
return res.json({ ...summary, notes_count: notesCount })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUser failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserActions(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(
|
||||||
|
await moderation.userActions(req.params.discordId, { type: typeParam(req), limit, offset }),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUserActions failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserNotes(req, res) {
|
||||||
|
try {
|
||||||
|
const notes = await modNotes.listForUser(req.params.discordId, {
|
||||||
|
includeAdminOnly: isAdmin(req),
|
||||||
|
})
|
||||||
|
return res.json(notes)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUserNotes failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserNote(req, res) {
|
||||||
|
try {
|
||||||
|
const visibility = req.body.visibility === 'admin_only' ? 'admin_only' : 'staff_only'
|
||||||
|
// admin_only notes can carry sensitive judgement calls — restrict to admins.
|
||||||
|
if (visibility === 'admin_only' && !isAdmin(req)) {
|
||||||
|
return res.status(403).json({ message: 'Only admins can add admin-only notes' })
|
||||||
|
}
|
||||||
|
const note = await modNotes.add({
|
||||||
|
discordUserId: req.params.discordId,
|
||||||
|
author: req.user,
|
||||||
|
body: req.body.body,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'moderation.note.add',
|
||||||
|
detail: { discordUserId: req.params.discordId, visibility },
|
||||||
|
})
|
||||||
|
return res.status(201).json(note)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('addUserNote failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSummary,
|
||||||
|
getRecent,
|
||||||
|
search,
|
||||||
|
getMembers,
|
||||||
|
getFilterHits,
|
||||||
|
getSpamHits,
|
||||||
|
getUser,
|
||||||
|
getUserActions,
|
||||||
|
getUserNotes,
|
||||||
|
addUserNote,
|
||||||
|
}
|
||||||
@@ -96,8 +96,24 @@ async function loginTotp(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout(req, res) {
|
// Clear the caller's cookie AND revoke this session server-side, so a copy of the
|
||||||
|
// token (proxy log, shared machine, XSS-exfiltrated cookie) can't keep being used
|
||||||
|
// after logout. attachSession populated req.session (best-effort) with the jti +
|
||||||
|
// expiry; if there was no valid session, there's simply nothing to revoke.
|
||||||
|
async function logout(req, res) {
|
||||||
clearAuthCookie(req, res)
|
clearAuthCookie(req, res)
|
||||||
|
try {
|
||||||
|
if (req.session?.sessionId) {
|
||||||
|
await sessionService.revokeSession(req.session.sessionId, {
|
||||||
|
userId: req.session.userId,
|
||||||
|
expiresAt: req.session.expiresAt,
|
||||||
|
})
|
||||||
|
await activity.log({ req, userId: req.session.userId, action: 'auth.logout' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Never fail the logout on a revocation/logging hiccup — the cookie is cleared.
|
||||||
|
log.error('logout revoke error', err)
|
||||||
|
}
|
||||||
return res.json({ message: 'Logged out.' })
|
return res.json({ message: 'Logged out.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { body } = require('express-validator')
|
|||||||
|
|
||||||
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||||
const { isLoggedIn } = require('../../../utils/auth')
|
const { isLoggedIn } = require('../../../utils/auth')
|
||||||
|
const { attachSession } = require('../../../auth/session.middleware')
|
||||||
const { loginLimiter } = require('../../../middleware/rateLimit')
|
const { loginLimiter } = require('../../../middleware/rateLimit')
|
||||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -65,8 +66,11 @@ authRouter.post(
|
|||||||
authRouter.post(
|
authRouter.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
// #swagger.tags = ['Auth']
|
// #swagger.tags = ['Auth']
|
||||||
// #swagger.summary = 'Log out (clear the session cookie)'
|
// #swagger.summary = 'Log out (clear the cookie and revoke this session)'
|
||||||
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||||
|
// Best-effort attach (never rejects) so the controller can revoke this session's
|
||||||
|
// jti — logout stays a no-op for an already-anonymous caller.
|
||||||
|
attachSession,
|
||||||
logout,
|
logout,
|
||||||
)
|
)
|
||||||
authRouter.get(
|
authRouter.get(
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const registry = require('../../../auth/providers/registry')
|
|||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const ssoState = require('../../../auth/ssoState')
|
const ssoState = require('../../../auth/ssoState')
|
||||||
const token = require('../../../auth/token')
|
const token = require('../../../auth/token')
|
||||||
|
const totp = require('../../../utils/totp')
|
||||||
|
const botScore = require('../../../middleware/botScore')
|
||||||
|
const loginProtection = require('../../../middleware/loginProtection')
|
||||||
|
const { needsTotp } = require('./auth.controller')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('sso')
|
const log = require('../../../utils/logger')('sso')
|
||||||
|
|
||||||
@@ -56,6 +60,13 @@ function txCookieOptions(req) {
|
|||||||
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
|
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpOnly cookie carrying the staged pending-TOTP token for the second-factor
|
||||||
|
// step. Same standard options; TTL matches the token so a stale cookie can't
|
||||||
|
// outlive the challenge it holds.
|
||||||
|
function totpCookieOptions(req) {
|
||||||
|
return { ...token.cookieOptions(req), maxAge: 5 * 60 * 1000 }
|
||||||
|
}
|
||||||
|
|
||||||
// GET /auth/providers — public discovery. Never touches secrets.
|
// GET /auth/providers — public discovery. Never touches secrets.
|
||||||
async function listProviders(req, res) {
|
async function listProviders(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -148,6 +159,23 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
|||||||
if (!user) return res.redirect(loginError('not_linked'))
|
if (!user) return res.redirect(loginError('not_linked'))
|
||||||
|
|
||||||
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
||||||
|
|
||||||
|
// 2FA parity with the local login (auth.controller): if the account has TOTP
|
||||||
|
// enabled, an SSO sign-in must NOT bypass the second factor. Stage a signed,
|
||||||
|
// httpOnly challenge and route the browser through the TOTP form instead of
|
||||||
|
// minting a session here. See issue #31.
|
||||||
|
if (needsTotp(user)) {
|
||||||
|
const pending = ssoState.createTotpPending({
|
||||||
|
userId: user.id,
|
||||||
|
provider: providerId,
|
||||||
|
authMethod,
|
||||||
|
returnTo: sanitizeReturn(tx.returnTo) || undefined,
|
||||||
|
})
|
||||||
|
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
|
||||||
|
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
|
||||||
|
return res.redirect('/admin/login?sso_totp=1')
|
||||||
|
}
|
||||||
|
|
||||||
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||||
token.setAuthCookie(req, res, sessionToken)
|
token.setAuthCookie(req, res, sessionToken)
|
||||||
await users.recordLogin(user.id)
|
await users.recordLogin(user.id)
|
||||||
@@ -156,6 +184,44 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
|||||||
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
|
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
|
||||||
|
// Reads the staged pending-TOTP cookie, verifies the authenticator code, then
|
||||||
|
// mints the full session. Mirrors auth.controller.loginTotp: a wrong code is a
|
||||||
|
// failed attempt (backoff + bot score), and the response is JSON (the login page
|
||||||
|
// completes this step over fetch and then navigates to returnTo).
|
||||||
|
async function finishSsoTotp(req, res) {
|
||||||
|
const pending = ssoState.verifyTotpPending(req.cookies && req.cookies[ssoState.TOTP_COOKIE])
|
||||||
|
if (!pending) {
|
||||||
|
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await users.getRawById(pending.id)
|
||||||
|
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, req.body.code)) {
|
||||||
|
botScore.recordLoginFailure(req.ip)
|
||||||
|
loginProtection.recordFailure(req.ip)
|
||||||
|
log.warn('sso TOTP verify failed', { id: pending.id, ip: req.ip })
|
||||||
|
return res.status(401).json({ message: 'Invalid verification code.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second factor satisfied — clear the staged cookie and issue the real session.
|
||||||
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
||||||
|
loginProtection.recordSuccess(req.ip)
|
||||||
|
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
||||||
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||||
|
token.setAuthCookie(req, res, sessionToken)
|
||||||
|
await users.recordLogin(user.id)
|
||||||
|
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
|
||||||
|
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
|
||||||
|
return res.json({
|
||||||
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
|
returnTo: sanitizeReturn(pending.returnTo) || '/admin',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('sso totp error', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
||||||
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
||||||
async function finishLink(req, res, providerId, tx, profile) {
|
async function finishLink(req, res, providerId, tx, profile) {
|
||||||
@@ -173,4 +239,4 @@ async function finishLink(req, res, providerId, tx, profile) {
|
|||||||
return res.redirect(`/admin/account?linked=${providerId}`)
|
return res.redirect(`/admin/account?linked=${providerId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishLink }
|
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
|
const { body } = require('express-validator')
|
||||||
|
|
||||||
const ctrl = require('./sso.controller')
|
const ctrl = require('./sso.controller')
|
||||||
const { requireAuth } = require('../../../auth/session.middleware')
|
const { requireAuth } = require('../../../auth/session.middleware')
|
||||||
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
|
const { ssoStartLimiter, loginLimiter } = require('../../../middleware/rateLimit')
|
||||||
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
const ssoRouter = express.Router()
|
const ssoRouter = express.Router()
|
||||||
|
|
||||||
|
// Same throttling stack the local login/TOTP endpoints use — the SSO TOTP step is
|
||||||
|
// a code-guessing surface too (cheapest rejection first).
|
||||||
|
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
|
||||||
|
|
||||||
// Public discovery — the login page reads this to render provider buttons.
|
// Public discovery — the login page reads this to render provider buttons.
|
||||||
ssoRouter.get(
|
ssoRouter.get(
|
||||||
'/providers',
|
'/providers',
|
||||||
@@ -57,4 +64,22 @@ ssoRouter.get(
|
|||||||
ctrl.callback,
|
ctrl.callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Second factor for an SSO login whose account has TOTP enabled. The callback
|
||||||
|
// stages an httpOnly pending-TOTP cookie and bounces the browser to the login
|
||||||
|
// page (?sso_totp=1); the page posts the code here to finish and receive a session.
|
||||||
|
ssoRouter.post(
|
||||||
|
'/sso/totp',
|
||||||
|
// #swagger.tags = ['Auth · SSO']
|
||||||
|
// #swagger.summary = 'Complete an SSO login with a TOTP code'
|
||||||
|
// #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.'
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/SafeUser" }, returnTo: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
...loginGuards,
|
||||||
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
validate,
|
||||||
|
ctrl.finishSsoTotp,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = ssoRouter
|
module.exports = ssoRouter
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ publicRouter.get(
|
|||||||
// #swagger.tags = ['Public']
|
// #swagger.tags = ['Public']
|
||||||
// #swagger.summary = 'Site mode / status'
|
// #swagger.summary = 'Site mode / status'
|
||||||
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.'
|
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.'
|
||||||
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "live" } } } } } } */
|
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */
|
||||||
ctrl.getStatus,
|
ctrl.getStatus,
|
||||||
)
|
)
|
||||||
publicRouter.post(
|
publicRouter.post(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const botScore = require('./middleware/botScore')
|
|||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||||
const mailer = require('./utils/mailer')
|
const mailer = require('./utils/mailer')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||||
@@ -52,6 +53,15 @@ async function start() {
|
|||||||
await seedDefaults()
|
await seedDefaults()
|
||||||
await createInitialAdminFromEnv()
|
await createInitialAdminFromEnv()
|
||||||
|
|
||||||
|
// Clear out session-denylist rows whose token has already expired (dead weight).
|
||||||
|
// Best-effort — a prune failure must never block startup.
|
||||||
|
try {
|
||||||
|
const pruned = await revokedSessions.pruneExpired()
|
||||||
|
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('revoked-session prune failed', { error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
const mode = await settings.get('site_mode')
|
const mode = await settings.get('site_mode')
|
||||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ const doc = {
|
|||||||
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
||||||
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
||||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
||||||
|
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
|
||||||
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||||
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
||||||
],
|
],
|
||||||
@@ -144,7 +145,11 @@ const doc = {
|
|||||||
properties: {
|
properties: {
|
||||||
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
|
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
|
||||||
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
|
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
|
||||||
expiresIn: { type: 'integer', description: 'Access token lifetime in seconds.', example: 900 },
|
expiresIn: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").',
|
||||||
|
example: '15m',
|
||||||
|
},
|
||||||
user: { $ref: '#/components/schemas/SafeUser' },
|
user: { $ref: '#/components/schemas/SafeUser' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -173,27 +178,59 @@ const doc = {
|
|||||||
name: { type: 'string', maxLength: 100, example: 'Lord British' },
|
name: { type: 'string', maxLength: 100, example: 'Lord British' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Public discovery shape (GET /auth/providers) — enough for the login page
|
||||||
|
// to render a button and start the flow. Never exposes secrets or endpoints.
|
||||||
Provider: {
|
Provider: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', example: 'google' },
|
id: { type: 'string', example: 'google' },
|
||||||
name: { type: 'string', example: 'Google' },
|
name: { type: 'string', example: 'Google' },
|
||||||
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
icon: {
|
||||||
|
type: 'string',
|
||||||
|
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
|
||||||
|
example: 'google',
|
||||||
|
},
|
||||||
|
loginUrl: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Relative URL to begin the redirect flow.',
|
||||||
|
example: '/api/v1/auth/sso/google/start',
|
||||||
|
},
|
||||||
|
priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The
|
||||||
|
// client secret is write-only and NEVER returned — `hasSecret` reports
|
||||||
|
// whether one is stored. `builtin` marks google/discord (fixed kind/name),
|
||||||
|
// and `health` is the config-completeness check used to gate visibility.
|
||||||
ProviderConfig: {
|
ProviderConfig: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', example: 'okta' },
|
id: { type: 'string', example: 'okta' },
|
||||||
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
|
||||||
name: { type: 'string', example: 'Okta' },
|
name: { type: 'string', example: 'Okta' },
|
||||||
enabled: { type: 'boolean', example: true },
|
enabled: { type: 'boolean', example: true },
|
||||||
clientId: { type: 'string' },
|
clientId: { type: 'string' },
|
||||||
|
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
|
||||||
authorizeUrl: { type: 'string', format: 'uri' },
|
authorizeUrl: { type: 'string', format: 'uri' },
|
||||||
tokenUrl: { type: 'string', format: 'uri' },
|
tokenUrl: { type: 'string', format: 'uri' },
|
||||||
userinfoUrl: { type: 'string', format: 'uri' },
|
userinfoUrl: { type: 'string', format: 'uri' },
|
||||||
scopes: { type: 'string', example: 'openid email profile' },
|
scopes: { type: 'string', example: 'openid email profile' },
|
||||||
priority: { type: 'integer', example: 10 },
|
priority: { type: 'integer', example: 10 },
|
||||||
|
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
|
||||||
|
health: { $ref: '#/components/schemas/ProviderHealth' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ProviderHealth: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Config-completeness check that gates whether a provider is offered to end users.',
|
||||||
|
properties: {
|
||||||
|
valid: { type: 'boolean', example: true },
|
||||||
|
missing: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Names of required config fields that are still missing.',
|
||||||
|
items: { type: 'string' },
|
||||||
|
example: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ProviderCreateRequest: {
|
ProviderCreateRequest: {
|
||||||
@@ -220,11 +257,14 @@ const doc = {
|
|||||||
category: { type: 'string', example: 'news' },
|
category: { type: 'string', example: 'news' },
|
||||||
title: { type: 'string', example: 'Server maintenance this weekend' },
|
title: { type: 'string', example: 'Server maintenance this weekend' },
|
||||||
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
|
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
|
||||||
body: { type: 'string' },
|
excerpt: { type: 'string', nullable: true },
|
||||||
image_url: { type: 'string', example: '/uploads/1700000000-abcd.png' },
|
body: { type: 'string', nullable: true },
|
||||||
|
image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' },
|
||||||
published: { type: 'boolean', example: true },
|
published: { type: 'boolean', example: true },
|
||||||
|
author_id: { type: 'integer', nullable: true, example: 1 },
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
updated_at: { type: 'string', format: 'date-time' },
|
updated_at: { type: 'string', format: 'date-time' },
|
||||||
|
published_at: { type: 'string', format: 'date-time', nullable: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PostCreateRequest: {
|
PostCreateRequest: {
|
||||||
@@ -330,6 +370,84 @@ const doc = {
|
|||||||
required: ['ip'],
|
required: ['ip'],
|
||||||
properties: { ip: { type: 'string', example: '203.0.113.5' } },
|
properties: { ip: { type: 'string', example: '203.0.113.5' } },
|
||||||
},
|
},
|
||||||
|
// ── Actual mutation-response shapes ─────────────────────────────────────
|
||||||
|
// These endpoints do NOT return the generic { message } envelope; they echo
|
||||||
|
// the affected resource id/slug or a boolean flag. Documented here as-is so
|
||||||
|
// the spec matches the controllers. (The shapes are intentionally recorded
|
||||||
|
// rather than normalized — see the audit note if standardizing later.)
|
||||||
|
AccountStatus: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Self-service account security status (GET /admin/account).',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', example: 1 },
|
||||||
|
username: { type: 'string', example: 'admin' },
|
||||||
|
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
||||||
|
totp_enabled: { type: 'boolean', example: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotpSetup: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Enrollment material returned by POST /account/totp/setup.',
|
||||||
|
properties: {
|
||||||
|
otpauthUrl: { type: 'string', example: 'otpauth://totp/UOMysticmoon:admin?secret=...' },
|
||||||
|
qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotpState: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Result of enabling/disabling 2FA.',
|
||||||
|
properties: { totp_enabled: { type: 'boolean', example: true } },
|
||||||
|
},
|
||||||
|
LinkedIdentity: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
provider: { type: 'string', example: 'google' },
|
||||||
|
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
|
||||||
|
linked_at: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SiteModeState: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Result of PUT /admin/site-mode.',
|
||||||
|
properties: {
|
||||||
|
site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' },
|
||||||
|
changed_at: { type: 'string', format: 'date-time' },
|
||||||
|
changed_by: { type: 'string', example: 'admin' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicStatus: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Public site status (GET /public/status).',
|
||||||
|
properties: {
|
||||||
|
mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' },
|
||||||
|
status_message: { type: 'string', example: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Delete/mutation acknowledgements — each echoes the affected resource key
|
||||||
|
// or a boolean flag rather than a { message } string.
|
||||||
|
DeletedId: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'integer', example: 12 } },
|
||||||
|
},
|
||||||
|
DeletedSlug: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { slug: { type: 'string', example: 'getting-started' } },
|
||||||
|
},
|
||||||
|
DeletedFlag: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { deleted: { type: 'boolean', example: true } },
|
||||||
|
},
|
||||||
|
UnlinkedFlag: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { unlinked: { type: 'boolean', example: true } },
|
||||||
|
},
|
||||||
|
UnbanResult: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ip: { type: 'string', example: '203.0.113.5' },
|
||||||
|
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
89
server/test/moderation.test.js
Normal file
89
server/test/moderation.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Unit tests for the moderation dashboard's pure reshaping/annotation logic.
|
||||||
|
// DB-free (like the rest of this suite) — the SQL layer is exercised manually
|
||||||
|
// against a dev database per the plan's verification steps.
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const moderation = require('../src/model/moderation/moderation.pure')
|
||||||
|
|
||||||
|
test('reshapeWindows: folds rows into windows and zero-fills missing types', () => {
|
||||||
|
const rows = [
|
||||||
|
{ action_type: 'ban', d1: 1, d7: 3, d30: 5 },
|
||||||
|
{ action_type: 'warn', d1: 0, d7: 2, d30: 9 },
|
||||||
|
]
|
||||||
|
const { windows } = moderation.reshapeWindows(rows)
|
||||||
|
assert.deepEqual(windows['24h'], { ban: 1, kick: 0, mute: 0, warn: 0 })
|
||||||
|
assert.deepEqual(windows['7d'], { ban: 3, kick: 0, mute: 0, warn: 2 })
|
||||||
|
assert.deepEqual(windows['30d'], { ban: 5, kick: 0, mute: 0, warn: 9 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: coerces string/decimal SUM results to numbers', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([{ action_type: 'mute', d1: '2', d7: '2', d30: '4' }])
|
||||||
|
assert.strictEqual(windows['24h'].mute, 2)
|
||||||
|
assert.strictEqual(windows['30d'].mute, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: ignores unknown action types (e.g. future enum values)', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([{ action_type: 'filter_hit', d1: 9, d7: 9, d30: 9 }])
|
||||||
|
assert.deepEqual(windows['24h'], { ban: 0, kick: 0, mute: 0, warn: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: empty input yields all-zero windows', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([])
|
||||||
|
assert.deepEqual(windows, {
|
||||||
|
'24h': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
'7d': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
'30d': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: flags automated when staff id matches the bot application id', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: string/number snowflake mismatch still matches (coerced)', () => {
|
||||||
|
// mod_actions stores staff_user_id as VARCHAR, but bot_config.application_id
|
||||||
|
// could arrive as a number — the compare must coerce both sides.
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: 999, target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: staff action (id differs from bot) is not automated', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '111', target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: no bot application id configured means nothing is automated', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], null)
|
||||||
|
assert.equal(row.is_automated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: folds joined identity columns into linked_account', () => {
|
||||||
|
const [row] = moderation.annotate(
|
||||||
|
[{ staff_user_id: '1', target_site_user_id: 7, target_site_username: 'perry' }],
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
assert.deepEqual(row.linked_account, { id: 7, username: 'perry' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: no linked identity yields null linked_account', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
|
||||||
|
assert.equal(row.linked_account, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('windowValue: picks the right column per window key and coerces to number', () => {
|
||||||
|
const row = { d1: '2', d7: 5, d30: '11' }
|
||||||
|
assert.strictEqual(moderation.windowValue(row, '24h'), 2)
|
||||||
|
assert.strictEqual(moderation.windowValue(row, '7d'), 5)
|
||||||
|
assert.strictEqual(moderation.windowValue(row, '30d'), 11)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('windowValue: null row (no rows in window) yields 0', () => {
|
||||||
|
assert.strictEqual(moderation.windowValue(null, '24h'), 0)
|
||||||
|
assert.strictEqual(moderation.windowValue(undefined, '30d'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('windowValue: null sum column yields 0', () => {
|
||||||
|
assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0)
|
||||||
|
})
|
||||||
@@ -10,7 +10,10 @@ const { test, after } = require('node:test')
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
const sessionService = require('../src/auth/session.service')
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const ssoState = require('../src/auth/ssoState')
|
||||||
const authFacade = require('../src/utils/auth')
|
const authFacade = require('../src/utils/auth')
|
||||||
|
const revokedSessions = require('../src/model/revokedSessions/revokedSessions.model')
|
||||||
|
const usersModel = require('../src/model/users/users.model')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
after(() => db.close())
|
||||||
@@ -36,6 +39,9 @@ test('createSession → validateSession round-trips a Session object', () => {
|
|||||||
assert.equal(session.authMethod, 'local')
|
assert.equal(session.authMethod, 'local')
|
||||||
assert.ok(session.sessionId, 'sessionId (jti) is present')
|
assert.ok(session.sessionId, 'sessionId (jti) is present')
|
||||||
assert.equal(typeof session.createdAt, 'number')
|
assert.equal(typeof session.createdAt, 'number')
|
||||||
|
// exp is carried so logout can set a self-pruning denylist row expiry.
|
||||||
|
assert.equal(typeof session.expiresAt, 'number')
|
||||||
|
assert.ok(session.expiresAt > session.createdAt, 'expiresAt is after createdAt')
|
||||||
|
|
||||||
// Validating the same token off a request yields the same identity.
|
// Validating the same token off a request yields the same identity.
|
||||||
const validated = sessionService.validateSession(reqWithCookie(token))
|
const validated = sessionService.validateSession(reqWithCookie(token))
|
||||||
@@ -68,6 +74,28 @@ test('a partial (TOTP challenge) token is NOT a valid session', () => {
|
|||||||
assert.equal(sessionService.decodeIdentity(challenge), null)
|
assert.equal(sessionService.decodeIdentity(challenge), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('an SSO transaction (sso_tx) flow token is NOT a valid session (issue #32)', () => {
|
||||||
|
// The sso_tx cookie is a JWT signed with the same secret as sessions, carrying
|
||||||
|
// kind:'sso_tx' and id:'sso' but no `stage`. Before the fix it passed the
|
||||||
|
// blocklist check and validated as a bogus { userId:'sso' } session, which fooled
|
||||||
|
// non-DB identity checks (e.g. siteMode's maintenance-preview bypass).
|
||||||
|
const { txToken } = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||||
|
assert.equal(sessionService.validateSession(reqWithCookie(txToken)), null)
|
||||||
|
assert.equal(sessionService.validateSession(reqWithBearer(txToken)), null)
|
||||||
|
assert.equal(sessionService.decodeIdentity(txToken), null)
|
||||||
|
assert.equal(sessionService.validateBearerToken(txToken), null)
|
||||||
|
// And the historical facade used by siteMode must report no user.
|
||||||
|
assert.equal(authFacade.getUserFromRequest(reqWithCookie(txToken)), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a bare identity token with no session marker is NOT a valid session', () => {
|
||||||
|
// A JWT carrying only { id, username, role } (e.g. a legacy token, or one minted
|
||||||
|
// for some other purpose) must not validate: sessions are positively typed.
|
||||||
|
const bare = authFacade.signToken(USER)
|
||||||
|
assert.equal(sessionService.validateSession(reqWithCookie(bare)), null)
|
||||||
|
assert.equal(sessionService.decodeIdentity(bare), null)
|
||||||
|
})
|
||||||
|
|
||||||
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
|
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
|
||||||
const challenge = sessionService.createPartialSession(USER)
|
const challenge = sessionService.createPartialSession(USER)
|
||||||
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
|
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
|
||||||
@@ -86,10 +114,62 @@ test('validateSession / decodeIdentity return null for missing or garbage input'
|
|||||||
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
|
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('revoke / invalidate stubs report success without throwing', () => {
|
test('revokeSession denylists the jti with the token expiry', async () => {
|
||||||
assert.equal(sessionService.revokeSession('sid-1'), true)
|
// Stub the store (the DB is intentionally unreachable in these tests) and
|
||||||
assert.equal(sessionService.invalidateSession('sid-1'), true)
|
// capture what the seam persists. sessionService holds the same module object,
|
||||||
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
|
// so overwriting the method here is what it calls.
|
||||||
|
const calls = []
|
||||||
|
const orig = revokedSessions.revoke
|
||||||
|
revokedSessions.revoke = async (args) => { calls.push(args); return 1 }
|
||||||
|
try {
|
||||||
|
const exp = Date.now() + 60_000
|
||||||
|
const ok = await sessionService.revokeSession('sid-1', { userId: USER.id, expiresAt: exp })
|
||||||
|
assert.equal(ok, true)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.equal(calls[0].jti, 'sid-1')
|
||||||
|
assert.equal(calls[0].userId, USER.id)
|
||||||
|
assert.equal(calls[0].expiresAt, exp)
|
||||||
|
} finally {
|
||||||
|
revokedSessions.revoke = orig
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revokeSession is a no-op (returns false) without a sessionId', async () => {
|
||||||
|
let called = false
|
||||||
|
const orig = revokedSessions.revoke
|
||||||
|
revokedSessions.revoke = async () => { called = true; return 1 }
|
||||||
|
try {
|
||||||
|
assert.equal(await sessionService.revokeSession(undefined), false)
|
||||||
|
assert.equal(called, false, 'nothing is persisted when there is no jti')
|
||||||
|
} finally {
|
||||||
|
revokedSessions.revoke = orig
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isSessionRevoked delegates to the denylist (and short-circuits on null)', async () => {
|
||||||
|
const orig = revokedSessions.isRevoked
|
||||||
|
revokedSessions.isRevoked = async (jti) => jti === 'revoked-sid'
|
||||||
|
try {
|
||||||
|
assert.equal(await sessionService.isSessionRevoked('revoked-sid'), true)
|
||||||
|
assert.equal(await sessionService.isSessionRevoked('fresh-sid'), false)
|
||||||
|
assert.equal(await sessionService.isSessionRevoked(null), false)
|
||||||
|
} finally {
|
||||||
|
revokedSessions.isRevoked = orig
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalidateAllUserSessions bumps the user cutoff (and guards a missing id)', async () => {
|
||||||
|
const ids = []
|
||||||
|
const orig = usersModel.invalidateSessions
|
||||||
|
usersModel.invalidateSessions = async (id) => { ids.push(id); return undefined }
|
||||||
|
try {
|
||||||
|
assert.equal(await sessionService.invalidateAllUserSessions(USER.id), true)
|
||||||
|
assert.deepEqual(ids, [USER.id])
|
||||||
|
assert.equal(await sessionService.invalidateAllUserSessions(undefined), false)
|
||||||
|
assert.deepEqual(ids, [USER.id], 'no bump when userId is missing')
|
||||||
|
} finally {
|
||||||
|
usersModel.invalidateSessions = orig
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {
|
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const activity = require('../src/model/activity/activity.model')
|
|||||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||||
const registry = require('../src/auth/providers/registry')
|
const registry = require('../src/auth/providers/registry')
|
||||||
|
const totp = require('../src/utils/totp')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
after(() => db.close())
|
||||||
@@ -114,3 +115,75 @@ test('bad state (CSRF) → rejected before any provider work', async () => {
|
|||||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 2FA parity: SSO must not bypass TOTP (issue #31) ────────────────────────
|
||||||
|
|
||||||
|
test('linked account with TOTP → staged challenge, NO session, routed to TOTP', async () => {
|
||||||
|
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
|
||||||
|
users.getById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1 })
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(makeReq(tx), res)
|
||||||
|
|
||||||
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no full session before 2FA')
|
||||||
|
assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged')
|
||||||
|
assert.equal(res.redirectedTo, '/admin/login?sso_totp=1')
|
||||||
|
assert.equal(logged.length, 0, 'login not logged until the second factor passes')
|
||||||
|
// The staged cookie carries the resolved context and is not a usable session.
|
||||||
|
const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE])
|
||||||
|
assert.equal(pending.id, 7)
|
||||||
|
assert.equal(pending.provider, 'google')
|
||||||
|
assert.equal(pending.returnTo, '/admin/posts')
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeTotpReq(pendingToken, code) {
|
||||||
|
return {
|
||||||
|
cookies: pendingToken ? { [ssoState.TOTP_COOKIE]: pendingToken } : {},
|
||||||
|
body: { code },
|
||||||
|
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('finishSsoTotp: correct code → session issued, pending cookie cleared, login logged', async () => {
|
||||||
|
users.getRawById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => true
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/admin/posts' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(makeTotpReq(pending, '123456'), res)
|
||||||
|
|
||||||
|
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set after 2FA')
|
||||||
|
assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE), 'pending-TOTP cookie cleared')
|
||||||
|
assert.equal(res.body.returnTo, '/admin/posts')
|
||||||
|
assert.equal(res.body.user.id, 7)
|
||||||
|
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
||||||
|
assert.equal(logged.at(-1).detail.totp, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finishSsoTotp: wrong code → 401, no session', async () => {
|
||||||
|
users.getRawById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => false
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(makeTotpReq(pending, '000000'), res)
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.match(res.body.message, /Invalid verification code/)
|
||||||
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finishSsoTotp: missing/expired pending cookie → 401 expired', async () => {
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(makeTotpReq(null, '123456'), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.match(res.body.message, /expired/i)
|
||||||
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finishSsoTotp: a local /login/totp challenge is not accepted here', async () => {
|
||||||
|
// A stage:'totp' token without kind:'sso_totp' must be rejected by this endpoint.
|
||||||
|
const localChallenge = token.signTotpChallenge({ id: 7 })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(makeTotpReq(localChallenge, '123456'), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.match(res.body.message, /expired/i)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
// Point the DB at a closed port before requiring the auth layer: session.service
|
||||||
|
// (used by one test below) pulls in the users model → db pool at load, and an idle
|
||||||
|
// pool would keep this process alive. None of these tests touch the database.
|
||||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
const { test } = require('node:test')
|
const { test, after } = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
|
||||||
const ssoState = require('../src/auth/ssoState')
|
const ssoState = require('../src/auth/ssoState')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
test('createTx → verifyTx round-trips the flow payload', () => {
|
test('createTx → verifyTx round-trips the flow payload', () => {
|
||||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
||||||
@@ -36,3 +44,27 @@ test('verifyTx rejects a non-tx token', () => {
|
|||||||
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
|
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
|
||||||
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
|
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('createTotpPending → verifyTotpPending round-trips the SSO 2FA context', () => {
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/admin/posts' })
|
||||||
|
const payload = ssoState.verifyTotpPending(pending)
|
||||||
|
assert.ok(payload)
|
||||||
|
assert.equal(payload.id, 7)
|
||||||
|
assert.equal(payload.provider, 'google')
|
||||||
|
assert.equal(payload.authMethod, 'google')
|
||||||
|
assert.equal(payload.returnTo, '/admin/posts')
|
||||||
|
assert.equal(payload.stage, 'totp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a pending-TOTP token is NOT accepted as a session (stage + kind reject it)', () => {
|
||||||
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google' })
|
||||||
|
assert.equal(sessionService.decodeIdentity(pending), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verifyTotpPending rejects a plain session and a bare TOTP challenge', () => {
|
||||||
|
const token = require('../src/auth/token')
|
||||||
|
assert.equal(ssoState.verifyTotpPending(token.signToken({ id: 1, username: 'a', role: 'admin' })), null)
|
||||||
|
assert.equal(ssoState.verifyTotpPending(token.signTotpChallenge({ id: 1 })), null)
|
||||||
|
assert.equal(ssoState.verifyTotpPending(null), null)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user