diff --git a/README.md b/README.md index fbaebc5..b556b8a 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,15 @@ 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` 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/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/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..710bed4 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -5,39 +5,40 @@ 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 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,13 +51,17 @@ 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'; @@ -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) => { @@ -409,10 +471,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 +514,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 +553,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 +568,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 +578,7 @@ const createDashboardApp = () => { return res.json({ user: req.session.user, expiresAt: req.session.expiresAt, + csrfToken: req.session.csrfToken, }); }); @@ -554,8 +641,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 +696,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 +773,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 +816,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/index.js b/index.js index e5dd771..f818d43 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,16 @@ 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'); @@ -26,4 +34,10 @@ for (const { event } of eventModules) { } } +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/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.js b/public/dashboard.js index c300854..49c4042 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -580,6 +580,7 @@ const updateBotStatusControls = () => { const loadSession = async () => { const data = await fetchDashboardJson('/api/me'); elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user); + dashboardApi.setCsrfToken(data.csrfToken); if (data.expiresAt) { const expires = new Date(data.expiresAt); elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`; @@ -993,7 +994,13 @@ const initDashboard = async () => { } elements.logoutButton.addEventListener('click', async () => { - await fetch('/auth/logout', {method: 'POST', credentials: 'include'}); + await fetch('/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { + ...(dashboardApi.getCsrfToken() ? {'X-CSRF-Token': dashboardApi.getCsrfToken()} : {}), + }, + }); window.location.href = '/'; });