diff --git a/README.md b/README.md index fbaebc5..40e9a4e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Im going to make a To-do List for the features i want to implement for my Bot. 3. **Moderation Tools** - [x] Add commands to mute, kick, and temp_ban users. + - [x] Add `/ban-role` to ban members with a selected role after confirmation. - [ ] Create an auto-moderation feature to delete spam or inappropriate content. - [x] Implement a warning system with a strike counter. @@ -90,3 +91,36 @@ Example document stored in the `discord.guild_permissions` collection: } } ``` + +--- + +### **Configuration** +Set secrets via environment variables, or copy `config.example.json` to `config.json` and fill in your own values. + +Recommended env vars: +- `DISCORD_BOT_TOKEN` +- `DISCORD_CLIENT_ID` +- `DISCORD_CLIENT_SECRET` +- `DISCORD_REDIRECT_URI` +- `MONGODB_URI` + +### **Banning members by role** + +Use `/ban-role role:@Role reason:Reason for the bans`. The bot privately previews the +number of eligible members and asks you to confirm within 60 seconds. Bans are permanent +and existing messages are kept. Both you and the bot need **Ban Members** permission. +The command skips you, the server owner, the bot itself, and members whose highest role +is equal to or above yours or the bot's (the server owner bypasses their own hierarchy check). +`@everyone` cannot be selected. Only members included in the preview can be banned; +their role membership and hierarchy are checked again before each ban. + +Enable **Server Members Intent** under **Bot > Privileged Gateway Intents** in the +[Discord Developer Portal](https://discord.com/developers/applications) so the bot can +[list all guild members](https://docs.discord.com/developers/resources/guild#list-guild-members). +Member discovery uses paginated REST requests, so no Gateway intent change is needed in `index.js`. +Run `npm run deploy:commands` and restart the bot after installing the command. + +The result reports successful bans, skipped members, failed requests, and any moderation +record failures separately. Long batches stop after about 14 minutes and report how many +members remain; run the command again to preview and continue. `/ban-role` also appears +automatically in `/help` and the dashboard's moderation permission settings. diff --git a/commands/db/test.js b/commands/db/test.js index ad584a2..896132d 100644 --- a/commands/db/test.js +++ b/commands/db/test.js @@ -14,6 +14,12 @@ module.exports = { async execute(interaction) { const dbClient = await connectToDatabase(); const message = interaction.options.getString('message'); + if (message.length > 500) { + return interaction.reply({ + content: 'Message must be 500 characters or fewer.', + ephemeral: true, + }); + } const db = dbClient.db('discord'); const collection = db.collection('test'); await collection.insertOne({ userId: interaction.user.id, message: message }); diff --git a/commands/db/transfer.js b/commands/db/transfer.js index dab7bdf..db5c1a6 100644 --- a/commands/db/transfer.js +++ b/commands/db/transfer.js @@ -40,22 +40,21 @@ module.exports = { } const senderId = interaction.user.id; - const senderData = await collection.findOne({ userID: senderId }); - const senderBalance = senderData?.balance ?? 0; + const debitResult = await collection.findOneAndUpdate( + { userID: senderId, balance: { $gte: amount } }, + { $inc: { balance: -amount } }, + { returnDocument: 'after' } + ); - if (senderBalance < amount) { + if (!debitResult.value) { + const senderData = await collection.findOne({ userID: senderId }); + const senderBalance = senderData?.balance ?? 0; return interaction.reply({ content: `You only have **${senderBalance}** coins available.`, ephemeral: true, }); } - await collection.updateOne( - { userID: senderId }, - { $inc: { balance: -amount } }, - { upsert: true } - ); - await collection.updateOne( { userID: target.id }, { $inc: { balance: amount } }, diff --git a/commands/moderation/ban_role.js b/commands/moderation/ban_role.js new file mode 100644 index 0000000..be9ea15 --- /dev/null +++ b/commands/moderation/ban_role.js @@ -0,0 +1,225 @@ +const { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + MessageFlags, + PermissionFlagsBits, + SlashCommandBuilder, +} = require('discord.js'); +const { recordModerationAction } = require('../../services/moderationRecords'); +const { isCommandAllowed } = require('../../services/permissions'); + +const activeGuilds = new Set(); + +const fetchModerator = async (interaction) => { + const [moderator, bot] = await Promise.all([ + interaction.guild.members.fetch({ user: interaction.user.id, force: true }), + interaction.guild.members.fetchMe({ force: true }), + ]); + if (!moderator.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('You need the Ban Members permission to use this command.'); + } + if (!bot.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('I need the Ban Members permission to use this command.'); + } + return moderator; +}; + +const canBan = (member, moderator, interaction) => + member.id !== interaction.user.id && + member.id !== interaction.client.user.id && + member.id !== interaction.guild.ownerId && + member.bannable && + (moderator.id === interaction.guild.ownerId || + moderator.roles.highest.comparePositionTo(member.roles.highest) > 0); + +module.exports = { + mod: true, + permissionGroup: 'moderation', + category: 'Moderation', + data: new SlashCommandBuilder() + .setName('ban-role') + .setDescription('Permanently ban members with a role after confirming a preview.') + .setDMPermission(false) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addRoleOption(option => option + .setName('role') + .setDescription('The role whose members should be banned') + .setRequired(true)) + .addStringOption(option => option + .setName('reason') + .setDescription('The reason for banning these members') + .setMinLength(1) + .setMaxLength(400) + .setRequired(true)), + async execute(interaction) { + const guild = interaction.guild; + const reject = (content) => interaction.reply({ content, flags: MessageFlags.Ephemeral }); + if (!guild) { + return reject('This command can only be used inside a server.'); + } + const role = interaction.options.getRole('role'); + const reason = interaction.options.getString('reason').trim(); + if (role.id === guild.id) { + return reject('You cannot use this command with @everyone.'); + } + if (!reason) { + return reject('Please provide a reason for the bans.'); + } + if (activeGuilds.has(guild.id)) { + return reject('A role ban is already being previewed or processed in this server.'); + } + + activeGuilds.add(guild.id); + const counts = { banned: 0, skipped: 0, failed: 0, logFailed: 0 }; + let started = false; + let editReply = (payload) => interaction.editReply(payload); + const summary = () => `Banned: ${counts.banned}. Skipped: ${counts.skipped}. Failed: ${counts.failed}.` + + (counts.logFailed ? ` Moderation records could not be saved for ${counts.logFailed} successful bans.` : ''); + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await guild.roles.fetch(); + const moderator = await fetchModerator(interaction); + const candidateIds = []; + let after; + // REST pagination includes uncached members without requesting a full Gateway member cache. + do { + let page; + try { + page = await guild.members.list({ limit: 1000, after, cache: false }); + } catch (error) { + console.error('Failed to list members for role ban:', error); + return await editReply({ + content: 'Could not load all server members. Check that Server Members Intent is enabled ' + + 'in the Discord Developer Portal, then try again. No members were banned.', + }); + } + for (const member of page.values()) { + if (!member.roles.cache.has(role.id)) continue; + if (canBan(member, moderator, interaction)) candidateIds.push(member.id); + else counts.skipped++; + } + if (page.size < 1000) break; + after = page.lastKey(); + } while (true); + + if (!candidateIds.length) { + return await editReply({ + content: `No eligible members have <@&${role.id}>. Skipped: ${counts.skipped}.`, + allowedMentions: { parse: [] }, + }); + } + + const confirmId = `ban-role:confirm:${interaction.id}`; + const cancelId = `ban-role:cancel:${interaction.id}`; + const reply = await editReply({ + content: `Permanently ban **${candidateIds.length}** members with <@&${role.id}>?\n` + + `Skipped: ${counts.skipped} (yourself, the server owner, this bot, or members outside the role hierarchy).\n` + + `Reason: ${reason}\nExisting messages will be kept. Confirm within 60 seconds.`, + allowedMentions: { parse: [] }, + components: [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(confirmId).setLabel('Ban members').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId(cancelId).setLabel('Cancel').setStyle(ButtonStyle.Secondary), + )], + }); + + let confirmation; + try { + confirmation = await reply.awaitMessageComponent({ + componentType: ComponentType.Button, + time: 60_000, + filter: component => component.user.id === interaction.user.id && + [confirmId, cancelId].includes(component.customId), + }); + } catch (error) { + return await editReply({ content: 'Confirmation expired. No members were banned.', components: [] }); + } + await confirmation.update({ + content: confirmation.customId === cancelId ? 'Cancelled. No members were banned.' : 'Checking permissions and banning members...', + components: [], + }); + // Use the fresh component token for the final result of a potentially long batch. + editReply = (payload) => confirmation.editReply(payload); + if (confirmation.customId === cancelId) return; + + await guild.roles.fetch(); + const currentModerator = await fetchModerator(interaction); + const allowed = await isCommandAllowed({ + guildId: guild.id, + userId: interaction.user.id, + member: currentModerator, + commandName: 'ban-role', + permissionGroup: 'moderation', + }); + if (!allowed) { + return await editReply({ content: 'You no longer have permission to use this command. No members were banned.' }); + } + + started = true; + let processed = 0; + const deadline = Date.now() + 14 * 60_000; + for (const memberId of candidateIds) { + if (Date.now() >= deadline) break; + // Permissions and role membership may have changed since the preview. + const freshModerator = await fetchModerator(interaction); + let member; + try { + member = await guild.members.fetch({ user: memberId, force: true, cache: false }); + } catch (error) { + if (error.code === 10007) counts.skipped++; // Member left the server. + else { + counts.failed++; + console.error(`Failed to fetch role-ban member ${memberId}:`, error); + } + processed++; + continue; + } + processed++; + if (!member.roles.cache.has(role.id) || !canBan(member, freshModerator, interaction)) { + counts.skipped++; + continue; + } + try { + await guild.bans.create(memberId, { + reason: `Role ban (${role.id}) by ${interaction.user.id}: ${reason}`, + deleteMessageSeconds: 0, + }); + counts.banned++; + } catch (error) { + counts.failed++; + console.error(`Failed to ban member ${memberId} with role ${role.id}:`, error); + continue; + } + try { + await recordModerationAction({ + guildId: guild.id, + userId: memberId, + action: 'ban', + moderatorId: interaction.user.id, + reason, + metadata: { command: 'ban-role', roleId: role.id }, + stateUpdates: { isBanned: true, bannedUntil: null }, + }); + } catch (error) { + counts.logFailed++; + console.error(`Failed to record role ban for ${memberId}:`, error); + } + } + const remaining = candidateIds.length - processed; + return await editReply({ + content: `${summary()}` + (remaining ? ` Time limit reached; ${remaining} members were not processed. Run the command again to continue.` : ''), + components: [], + }); + } catch (error) { + console.error('Role ban stopped:', error); + return await editReply({ + content: `Role ban stopped: ${error.message}\n${started ? summary() + ' Remaining members were not processed.' : 'No members were banned.'}`, + components: [], + allowedMentions: { parse: [] }, + }); + } finally { + activeGuilds.delete(guild.id); + } + }, +}; diff --git a/commands/moderation/kick.js b/commands/moderation/kick.js index 59fd524..71117a6 100644 --- a/commands/moderation/kick.js +++ b/commands/moderation/kick.js @@ -18,6 +18,12 @@ module.exports = { .setDescription('The reason of the kick')), category: 'Moderation', async execute(interaction) { + if (!interaction.guild) { + return interaction.reply({ + content: 'This command can only be used inside a server.', + ephemeral: true, + }); + } const user = interaction.options.getUser('target'); const reason = interaction.options.getString('reason') || 'No reason provided'; diff --git a/commands/moderation/mute.js b/commands/moderation/mute.js index 85ac8db..37f1f58 100644 --- a/commands/moderation/mute.js +++ b/commands/moderation/mute.js @@ -25,6 +25,12 @@ module.exports = { .setDescription('The reason of the mute')), category: 'Moderation', async execute(interaction) { + if (!interaction.guild) { + return interaction.reply({ + content: 'This command can only be used inside a server.', + ephemeral: true, + }); + } const target = interaction.options.getUser('target'); const duration = interaction.options.getInteger('duration'); const reason = interaction.options.getString('reason') || 'No reason provided'; diff --git a/commands/moderation/role.js b/commands/moderation/role.js index f1fbbf2..c42b7b6 100644 --- a/commands/moderation/role.js +++ b/commands/moderation/role.js @@ -48,6 +48,9 @@ module.exports = { .setRequired(true))), category: 'Moderation', async execute(interaction) { + if (!interaction.guild) { + return interaction.reply({ content: 'This command can only be used inside a server.', ephemeral: true }); + } const target = interaction.options.getUser('target'); const role = interaction.options.getRole('role'); const action = interaction.options.getSubcommand(); diff --git a/commands/moderation/temp_ban.js b/commands/moderation/temp_ban.js index fe97802..cdb58a6 100644 --- a/commands/moderation/temp_ban.js +++ b/commands/moderation/temp_ban.js @@ -18,6 +18,8 @@ module.exports = { option .setName('durationms') .setDescription('Duration of the temporary ban (in milliseconds).') + .setMinValue(60_000) + .setMaxValue(2_592_000_000) .setRequired(true)) .addStringOption(option => option @@ -31,23 +33,41 @@ module.exports = { .setMinValue(0).setMaxValue(7)), category: 'Moderation', async execute(interaction) { + if (!interaction.guild) { + return interaction.reply({ + content: 'This command can only be used inside a server.', + ephemeral: true, + }); + } const user = interaction.options.getUser('target'); const reason = interaction.options.getString('reason'); const durationms = interaction.options.getInteger('durationms'); const deleteMessages = interaction.options.getInteger('delete-messages'); - // If you want to parse time like "1d", "10m", etc., use ms package: - let banUntil; - try { - banUntil = Date.now() + durationms; - } catch (e) { - // If parsing fails, handle error + if (user.bot) { return interaction.reply({ - content: 'Invalid duration format. Please use something like "10m" or "1d".', - ephemeral: true + content: 'You cannot temp-ban a bot account.', + ephemeral: true, }); } + if (user.id === interaction.user.id) { + return interaction.reply({ + content: 'You cannot temp-ban yourself.', + ephemeral: true, + }); + } + + // If you want to parse time like "1d", "10m", etc., use ms package: + if (!Number.isFinite(durationms) || durationms <= 0) { + return interaction.reply({ + content: 'Invalid duration. Provide a positive number of milliseconds.', + ephemeral: true, + }); + } + + const banUntil = Date.now() + durationms; + // A function to send ephemeral messages async function sendEphemeral(message) { diff --git a/commands/moderation/warn.js b/commands/moderation/warn.js index 79163b8..1cb3448 100644 --- a/commands/moderation/warn.js +++ b/commands/moderation/warn.js @@ -4,6 +4,8 @@ const { getGuildSettings } = require('../../services/guildSettings'); const { recordModerationAction } = require('../../services/moderationRecords'); const { connectToDatabase } = require('../../db'); +const MAX_REASON_LENGTH = 500; + module.exports = { permissionGroup: 'moderation', data: new SlashCommandBuilder() @@ -31,6 +33,12 @@ module.exports = { ephemeral: true, }); } + if (reason.length > MAX_REASON_LENGTH) { + return interaction.reply({ + content: `Reason must be ${MAX_REASON_LENGTH} characters or fewer.`, + ephemeral: true, + }); + } const { warnings, strikeCount } = await addWarning({ guildId: interaction.guildId, diff --git a/commands/utility/help.js b/commands/utility/help.js index e1b63f1..c4503ea 100644 --- a/commands/utility/help.js +++ b/commands/utility/help.js @@ -7,7 +7,7 @@ module.exports = { category: 'Utility', async execute(interaction) { const commandDetails = interaction.client.commands - .map(cmd => ({ + .map((cmd) => ({ name: cmd.data.name, description: cmd.data.description, category: cmd.category ?? 'Other', @@ -26,7 +26,7 @@ module.exports = { .map((category) => ({ name: category, value: grouped[category] - .map(command => `• **/${command.name}** — ${command.description}`) + .map((command) => `- **/${command.name}**: ${command.description}`) .join('\n'), })); diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..fe91f31 --- /dev/null +++ b/config.example.json @@ -0,0 +1,11 @@ +{ + "token": "YOUR_DISCORD_BOT_TOKEN", + "clientId": "YOUR_DISCORD_CLIENT_ID", + "clientSecret": "YOUR_DISCORD_CLIENT_SECRET", + "mongodb": "mongodb://127.0.0.1:27017/botdb", + "redirectUri": "http://localhost:3000/auth/discord/callback", + "dashboardInvitePermissions": "0", + "dashboardSessionTtlMs": "86400000", + "dashboardClientUrl": "http://localhost:3000", + "trustProxy": false +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..84f59ae --- /dev/null +++ b/config.js @@ -0,0 +1,52 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const CONFIG_PATH = path.join(__dirname, 'config.json'); +let cachedConfig = null; + +const loadConfig = () => { + if (cachedConfig) { + return cachedConfig; + } + + if (!fs.existsSync(CONFIG_PATH)) { + cachedConfig = {}; + return cachedConfig; + } + + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); + const parsed = JSON.parse(raw); + cachedConfig = parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + console.error('Failed to parse config.json', { message: error.message }); + cachedConfig = {}; + } + + return cachedConfig; +}; + +const getConfig = () => loadConfig(); + +const getConfigValue = (envKey, ...configKeys) => { + const envValue = process.env[envKey]; + if (typeof envValue === 'string' && envValue.trim() !== '') { + return envValue.trim(); + } + + const config = loadConfig(); + for (const key of configKeys) { + const value = config?.[key]; + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); + } + } + + return undefined; +}; + +module.exports = { + CONFIG_PATH, + getConfig, + getConfigValue, +}; diff --git a/dashboardServer.js b/dashboardServer.js index 4895501..7021a1e 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -5,39 +5,41 @@ const axios = require('axios'); const express = require('express'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); +const { CONFIG_PATH, getConfig, getConfigValue } = require('./config'); +const { loadCommandModules } = require('./loaders/commands'); const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_SESSION_CLEANUP_MS = 60 * 60 * 1000; +const MAX_REASON_LENGTH = 500; +const MAX_MEMBER_QUERY_LENGTH = 64; +const JSON_BODY_LIMIT = '50kb'; const PERMISSION_BITS = { ADMINISTRATOR: 0x8n, MANAGE_GUILD: 0x20n, }; -const loadConfig = () => { - const configPath = path.join(__dirname, 'config.json'); - if (fs.existsSync(configPath)) { - return require(configPath); - } - return {}; -}; +const config = getConfig(); -const config = loadConfig(); - -const DISCORD_CLIENT_ID = - process.env.DISCORD_CLIENT_ID || - config.discordClientId || - config.clientId || - config.appId; -const DISCORD_CLIENT_SECRET = - process.env.DISCORD_CLIENT_SECRET || - config.discordClientSecret || - config.clientSecret; -const DISCORD_REDIRECT_URI = - process.env.DISCORD_REDIRECT_URI || - config.discordRedirectUri || - config.redirectUri; -const DISCORD_BOT_TOKEN = - process.env.DISCORD_BOT_TOKEN || - config.discordBotToken || - config.token; +const DISCORD_CLIENT_ID = getConfigValue( + 'DISCORD_CLIENT_ID', + 'discordClientId', + 'clientId', + 'appId', +); +const DISCORD_CLIENT_SECRET = getConfigValue( + 'DISCORD_CLIENT_SECRET', + 'discordClientSecret', + 'clientSecret', +); +const DISCORD_REDIRECT_URI = getConfigValue( + 'DISCORD_REDIRECT_URI', + 'discordRedirectUri', + 'redirectUri', +); +const DISCORD_BOT_TOKEN = getConfigValue( + 'DISCORD_BOT_TOKEN', + 'discordBotToken', + 'token', +); const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt( process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0', 10, @@ -50,18 +52,21 @@ const SESSION_TTL_MS = Number.parseInt( process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs, 10, ); +const TRUST_PROXY = + process.env.DASHBOARD_TRUST_PROXY === 'true' || + process.env.DASHBOARD_TRUST_PROXY === '1' || + Boolean(config.trustProxy); console.log('[oauth config]', { DISCORD_CLIENT_ID_present: Boolean(DISCORD_CLIENT_ID && String(DISCORD_CLIENT_ID).trim()), DISCORD_CLIENT_SECRET_present: Boolean(DISCORD_CLIENT_SECRET && String(DISCORD_CLIENT_SECRET).trim()), DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null, - configFilePath: path.join(__dirname, 'config.json'), - configFileExists: fs.existsSync(path.join(__dirname, 'config.json')), + configFilePath: CONFIG_PATH, + configFileExists: fs.existsSync(CONFIG_PATH), }); const DISCORD_API_BASE = 'https://discord.com/api'; const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2'; -const COMMANDS_PATH = path.join(__dirname, 'commands.json'); const GUILD_CACHE_TTL_MS = 5 * 60 * 1000; const BOT_GUILD_CACHE_TTL_MS = 60 * 1000; const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.'; @@ -72,6 +77,61 @@ const botGuildCache = { fetchedAt: 0, }; +process.on('unhandledRejection', (reason) => { + console.error('Unhandled promise rejection:', reason); +}); + +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); +}); + +const cleanupExpiredSessions = () => { + const now = Date.now(); + for (const [sessionId, session] of sessionStore.entries()) { + if (!session?.expiresAt || session.expiresAt <= now) { + sessionStore.delete(sessionId); + } + } +}; + +const createRateLimiter = ({ windowMs, max, keyFn }) => { + const hits = new Map(); + + const prune = () => { + const now = Date.now(); + for (const [key, entry] of hits.entries()) { + if (!entry || entry.resetAt <= now) { + hits.delete(key); + } + } + }; + + return (req, res, next) => { + const key = keyFn(req); + const now = Date.now(); + const entry = hits.get(key); + if (!entry || entry.resetAt <= now) { + hits.set(key, { count: 1, resetAt: now + windowMs }); + return next(); + } + if (entry.count >= max) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ error: 'RATE_LIMITED', retryAfterSeconds: retryAfter }); + } + entry.count += 1; + return next(); + }; +}; + +const ensureCsrf = (req, res, next) => { + const token = req.headers['x-csrf-token']; + if (!token || token !== req.session?.csrfToken) { + return res.status(403).json({ error: 'CSRF_INVALID', message: 'CSRF token missing or invalid.' }); + } + return next(); +}; + const parseCookies = (headerValue) => { const cookies = {}; if (!headerValue) { @@ -115,13 +175,15 @@ const clearCookie = (res, name) => { const createSession = (payload) => { const sessionId = crypto.randomBytes(32).toString('hex'); const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS); + const csrfToken = crypto.randomBytes(32).toString('hex'); sessionStore.set(sessionId, { ...payload, expiresAt, + csrfToken, }); - return {sessionId, expiresAt}; + return {sessionId, expiresAt, csrfToken}; }; const getSession = (sessionId) => { @@ -345,18 +407,34 @@ const isHttpsRequest = (req) => { }; const loadCommands = () => { - if (!fs.existsSync(COMMANDS_PATH)) { - return []; + const commandsPath = path.join(__dirname, 'commands'); + const { commands, duplicateCommands, invalidCommands, loadErrors } = loadCommandModules(commandsPath); + + if (invalidCommands.length > 0) { + console.warn('Skipping invalid commands while building dashboard command list.', { + invalidCommands, + }); } - try { - const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : []; - } catch (error) { - console.error('Failed to load commands.json', {message: error.message}); - return []; + if (duplicateCommands.length > 0) { + console.error('Duplicate command definitions detected while building dashboard command list.', { + duplicateCommands, + }); } + + if (loadErrors.length > 0) { + console.error('Failed to load one or more commands for the dashboard.', { + loadErrors: loadErrors.map(({ filePath, error }) => ({ + filePath, + message: error.message, + })), + }); + } + + return commands + .filter((command) => command.permissionGroup === 'moderation') + .map((command) => command.data.name) + .sort((left, right) => left.localeCompare(right)); }; const canManageGuild = (guild) => { @@ -409,10 +487,34 @@ const ensureManageableGuild = async (req, res, next) => { const createDashboardApp = () => { const app = express(); - app.use(express.json()); + app.disable('x-powered-by'); + if (TRUST_PROXY) { + app.set('trust proxy', 1); + } + app.use((req, res, next) => { + res.set('X-Content-Type-Options', 'nosniff'); + res.set('X-Frame-Options', 'DENY'); + res.set('Referrer-Policy', 'no-referrer'); + res.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + return next(); + }); + app.use(express.json({ limit: JSON_BODY_LIMIT })); app.use(express.static(path.join(__dirname, 'public'))); + app.use(createRateLimiter({ + windowMs: 60_000, + max: 120, + keyFn: (req) => req.ip || 'unknown', + })); - app.get('/auth/discord', (req, res) => { + const authLimiter = createRateLimiter({ + windowMs: 60_000, + max: 20, + keyFn: (req) => req.ip || 'unknown', + }); + + setInterval(cleanupExpiredSessions, DEFAULT_SESSION_CLEANUP_MS).unref(); + + app.get('/auth/discord', authLimiter, (req, res) => { if (!ensureOAuthConfig()) { return res.status(500).json({error: 'Missing Discord OAuth configuration'}); } @@ -428,7 +530,7 @@ const createDashboardApp = () => { return res.redirect(buildAuthorizationUrl(state)); }); - app.get('/auth/discord/callback', async (req, res) => { + app.get('/auth/discord/callback', authLimiter, async (req, res) => { if (!ensureOAuthConfig()) { return res.status(500).json({error: 'Missing Discord OAuth configuration'}); } @@ -467,7 +569,7 @@ const createDashboardApp = () => { return res.redirect('/dashboard.html'); } - return res.status(200).json({user}); + return res.status(200).json({user, csrfToken: session.csrfToken}); } catch (error) { console.error('Discord OAuth failed', { message: error.message, @@ -482,7 +584,7 @@ const createDashboardApp = () => { } }); - app.post('/auth/logout', ensureSession, (req, res) => { + app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => { destroySession(req.sessionId); clearCookie(res, 'dashboard_session'); return res.status(204).send(); @@ -492,6 +594,7 @@ const createDashboardApp = () => { return res.json({ user: req.session.user, expiresAt: req.session.expiresAt, + csrfToken: req.session.csrfToken, }); }); @@ -554,8 +657,12 @@ const createDashboardApp = () => { '/api/guilds/:guildId/permissions', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { try { + if (!req.body || typeof req.body !== 'object') { + return res.status(400).json({ error: 'Invalid permissions payload' }); + } const permissions = await updateGuildPermissions({ guildId: req.params.guildId, permissions: req.body || {}, @@ -605,6 +712,9 @@ const createDashboardApp = () => { if (query.length < 2) { return res.json({members: []}); } + if (query.length > MAX_MEMBER_QUERY_LENGTH) { + return res.status(400).json({ error: 'Query too long' }); + } try { const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query); const normalized = (members || []) @@ -679,17 +789,21 @@ const createDashboardApp = () => { '/api/guilds/:guildId/warnings/:userId', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { const {reason} = req.body || {}; - if (!reason) { + if (typeof reason !== 'string' || reason.trim().length === 0) { return res.status(400).json({error: 'Reason is required'}); } + if (reason.length > MAX_REASON_LENGTH) { + return res.status(400).json({error: `Reason must be ${MAX_REASON_LENGTH} characters or fewer`}); + } try { const result = await addWarning({ guildId: req.params.guildId, userId: req.params.userId, moderatorId: req.session.user?.id, - reason, + reason: reason.trim(), }); const warnings = (result.warnings || []).map((warning) => ({ ...warning, @@ -718,6 +832,7 @@ const createDashboardApp = () => { '/api/guilds/:guildId/warnings/:userId', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { try { const warnings = await clearWarnings({ diff --git a/db.js b/db.js index 1ad12b4..6055d36 100644 --- a/db.js +++ b/db.js @@ -1,11 +1,16 @@ -const {MongoClient, ServerApiVersion} = require('mongodb'); -const {mongodb} = require("./config.json"); +const { MongoClient, ServerApiVersion } = require('mongodb'); +const { getConfigValue } = require('./config'); let dbClient; async function connectToDatabase() { if (!dbClient) { - dbClient = new MongoClient(mongodb, { + const mongodbUri = getConfigValue('MONGODB_URI', 'mongodb', 'mongodbUri'); + if (!mongodbUri) { + throw new Error('Missing MongoDB connection string. Set MONGODB_URI or config.json mongodb.'); + } + + dbClient = new MongoClient(mongodbUri, { serverApi: { version: ServerApiVersion.v1, strict: true, diff --git a/deploy-commands.js b/deploy-commands.js index d7edebb..0a8bb8e 100644 --- a/deploy-commands.js +++ b/deploy-commands.js @@ -1,15 +1,43 @@ const path = require('node:path'); const { REST, Routes } = require('discord.js'); -const { clientId, token } = require('./config.json'); +const { getConfigValue } = require('./config'); const { loadCommandModules } = require('./loaders/commands'); + const commandsPath = path.join(__dirname, 'commands'); -const { commands, invalidCommands } = loadCommandModules(commandsPath); +const { + commands, + duplicateCommands, + invalidCommands, + loadErrors, +} = loadCommandModules(commandsPath); const commandsData = commands.map((command) => command.data.toJSON()); +const clientId = getConfigValue('DISCORD_CLIENT_ID', 'discordClientId', 'clientId', 'appId'); +const token = getConfigValue('DISCORD_BOT_TOKEN', 'discordBotToken', 'token'); for (const filePath of invalidCommands) { console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); } +for (const { commandName, filePath, existingFilePath } of duplicateCommands) { + console.error( + `[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`, + ); +} + +for (const { filePath, error } of loadErrors) { + console.error(`[ERROR] Failed to load command at ${filePath}:`, error); +} + +if (!clientId || !token) { + console.error('Missing Discord client ID or bot token. Set env vars or config.json values.'); + process.exit(1); +} + +if (duplicateCommands.length > 0 || loadErrors.length > 0) { + console.error('Command deployment aborted because one or more command modules failed validation.'); + process.exit(1); +} + const rest = new REST().setToken(token); (async () => { diff --git a/index.js b/index.js index e5dd771..1c83189 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,31 @@ const path = require('node:path'); const { Client, Collection, GatewayIntentBits } = require('discord.js'); -const { token } = require('./config.json'); +const { getConfigValue } = require('./config'); const { loadCommandModules } = require('./loaders/commands'); const { loadEventModules } = require('./loaders/events'); +process.on('unhandledRejection', (reason) => { + console.error('Unhandled promise rejection:', reason); +}); + +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); +}); + const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.commands = new Collection(); const commandsPath = path.join(__dirname, 'commands'); -const { commands, invalidCommands } = loadCommandModules(commandsPath); +const { + commands, + duplicateCommands, + invalidCommands, + loadErrors: commandLoadErrors, +} = loadCommandModules(commandsPath); const eventsPath = path.join(__dirname, 'events'); -const eventModules = loadEventModules(eventsPath); +const { + events: eventModules, + invalidEvents, + loadErrors: eventLoadErrors, +} = loadEventModules(eventsPath); for (const command of commands) { client.commands.set(command.data.name, command); @@ -18,6 +35,24 @@ for (const filePath of invalidCommands) { console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); } +for (const { commandName, filePath, existingFilePath } of duplicateCommands) { + console.error( + `[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`, + ); +} + +for (const { filePath, error } of commandLoadErrors) { + console.error(`[ERROR] Failed to load command at ${filePath}:`, error); +} + +for (const filePath of invalidEvents) { + console.log(`[WARNING] The event at ${filePath} is missing a required "name" or "execute" property.`); +} + +for (const { filePath, error } of eventLoadErrors) { + console.error(`[ERROR] Failed to load event at ${filePath}:`, error); +} + for (const { event } of eventModules) { if (event.once) { client.once(event.name, (...args) => event.execute(...args)); @@ -26,4 +61,15 @@ for (const { event } of eventModules) { } } +if (duplicateCommands.length > 0 || commandLoadErrors.length > 0 || eventLoadErrors.length > 0) { + console.error('Startup aborted because one or more modules failed validation.'); + process.exit(1); +} + +const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken'); +if (!token) { + console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.'); + process.exit(1); +} + client.login(token); diff --git a/loaders/commands.js b/loaders/commands.js index 94f0d35..2b51bbb 100644 --- a/loaders/commands.js +++ b/loaders/commands.js @@ -4,38 +4,74 @@ const path = require('node:path'); const isJavaScriptFile = (file) => file.endsWith('.js'); const getCommandFilePaths = (commandsPath) => { - const commandFolders = fs - .readdirSync(commandsPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); + const commandFilePaths = []; + const entries = fs.readdirSync(commandsPath, { withFileTypes: true }); - return commandFolders.flatMap((folder) => { - const folderPath = path.join(commandsPath, folder); - return fs - .readdirSync(folderPath) - .filter(isJavaScriptFile) - .map((file) => path.join(folderPath, file)); - }); + for (const entry of entries) { + const entryPath = path.join(commandsPath, entry.name); + if (entry.isDirectory()) { + commandFilePaths.push(...getCommandFilePaths(entryPath)); + continue; + } + if (entry.isFile() && isJavaScriptFile(entry.name)) { + commandFilePaths.push(entryPath); + } + } + + return commandFilePaths.sort((left, right) => left.localeCompare(right)); }; const loadCommandModules = (commandsPath) => { const commandFiles = getCommandFilePaths(commandsPath); const commands = []; const invalidCommands = []; + const duplicateCommands = []; + const loadErrors = []; + const commandNames = new Map(); for (const filePath of commandFiles) { - const command = require(filePath); - - if ('data' in command && 'execute' in command) { - commands.push(command); - } else { - invalidCommands.push(filePath); + let command; + try { + delete require.cache[require.resolve(filePath)]; + command = require(filePath); + } catch (error) { + loadErrors.push({ filePath, error }); + continue; } + + const commandName = command?.data?.name; + const isValidCommand = + typeof commandName === 'string' && + commandName.length > 0 && + typeof command.execute === 'function'; + + if (!isValidCommand) { + invalidCommands.push(filePath); + continue; + } + + if (commandNames.has(commandName)) { + duplicateCommands.push({ + commandName, + filePath, + existingFilePath: commandNames.get(commandName), + }); + continue; + } + + commandNames.set(commandName, filePath); + commands.push(command); } - return { commands, invalidCommands }; + return { + commands, + duplicateCommands, + invalidCommands, + loadErrors, + }; }; module.exports = { + getCommandFilePaths, loadCommandModules, }; diff --git a/loaders/events.js b/loaders/events.js index c91abd9..a55cb85 100644 --- a/loaders/events.js +++ b/loaders/events.js @@ -7,16 +7,41 @@ const getEventFilePaths = (eventsPath) => fs .readdirSync(eventsPath) .filter(isJavaScriptFile) + .sort((left, right) => left.localeCompare(right)) .map((file) => path.join(eventsPath, file)); const loadEventModules = (eventsPath) => { const eventFiles = getEventFilePaths(eventsPath); - return eventFiles.map((filePath) => ({ - filePath, - event: require(filePath), - })); + const events = []; + const invalidEvents = []; + const loadErrors = []; + + for (const filePath of eventFiles) { + let event; + try { + delete require.cache[require.resolve(filePath)]; + event = require(filePath); + } catch (error) { + loadErrors.push({ filePath, error }); + continue; + } + + if (typeof event?.name !== 'string' || typeof event?.execute !== 'function') { + invalidEvents.push(filePath); + continue; + } + + events.push({ filePath, event }); + } + + return { + events, + invalidEvents, + loadErrors, + }; }; module.exports = { + getEventFilePaths, loadEventModules, }; diff --git a/package.json b/package.json index afd5dba..a238e28 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "deploy:commands": "node deploy-commands.js", + "test": "node scripts/verify.js && node --test scripts/ban-role.test.js" }, "keywords": [], "author": "", diff --git a/public/app.js b/public/app.js index 1c34702..b4083f3 100644 --- a/public/app.js +++ b/public/app.js @@ -14,11 +14,21 @@ const createFetchError = async (response) => { return error; }; +let csrfToken = null; + +const setCsrfToken = (token) => { + csrfToken = typeof token === 'string' ? token : null; +}; + +const getCsrfToken = () => csrfToken; + const fetchJson = async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); const response = await fetch(url, { credentials: 'include', headers: { 'Content-Type': 'application/json', + ...(csrfToken && method !== 'GET' && method !== 'HEAD' ? {'X-CSRF-Token': csrfToken} : {}), ...(options.headers || {}), }, ...options, @@ -110,6 +120,7 @@ const initIndexPage = async () => { try { const data = await fetchJson('/api/me'); + setCsrfToken(data.csrfToken); sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`; loginButton.hidden = true; dashboardButton.hidden = false; @@ -131,7 +142,13 @@ const initIndexPage = async () => { logoutButton?.addEventListener('click', async () => { try { - await fetch('/auth/logout', {method: 'POST', credentials: 'include'}); + await fetch('/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { + ...(csrfToken ? {'X-CSRF-Token': csrfToken} : {}), + }, + }); sessionStatus.textContent = 'Logged out successfully.'; loginButton.hidden = false; dashboardButton.hidden = true; @@ -151,6 +168,8 @@ document.addEventListener('DOMContentLoaded', () => { window.dashboardApi = { fetchJson, + setCsrfToken, + getCsrfToken, showToast, setErrorBanner, clearErrorBanner, diff --git a/public/dashboard.html b/public/dashboard.html index c6257ea..bbd46c4 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -88,7 +88,7 @@
The bot isn’t in this server yet. Invite it to configure settings.
+The bot is not in this server yet. Invite it to configure settings.
You can review saved settings, but changes will stay inactive until the bot joins.
Restrict commands to roles or specific users.
+Review and edit restrictions faster with search, filters, and live summaries.
Search members or paste an ID.
+Search members or paste an ID, then manage all warnings from one target card.
+No user selected.
See warning history and add new entries for the selected target.
+The warning will be added to the currently selected target.