implement changes to commands and templates
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ module.exports = {
|
|||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const dbClient = await connectToDatabase();
|
const dbClient = await connectToDatabase();
|
||||||
const message = interaction.options.getString('message');
|
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 db = dbClient.db('discord');
|
||||||
const collection = db.collection('test');
|
const collection = db.collection('test');
|
||||||
await collection.insertOne({ userId: interaction.user.id, message: message });
|
await collection.insertOne({ userId: interaction.user.id, message: message });
|
||||||
|
|||||||
@@ -40,22 +40,21 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderId = interaction.user.id;
|
const senderId = interaction.user.id;
|
||||||
|
const debitResult = await collection.findOneAndUpdate(
|
||||||
|
{ userID: senderId, balance: { $gte: amount } },
|
||||||
|
{ $inc: { balance: -amount } },
|
||||||
|
{ returnDocument: 'after' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!debitResult.value) {
|
||||||
const senderData = await collection.findOne({ userID: senderId });
|
const senderData = await collection.findOne({ userID: senderId });
|
||||||
const senderBalance = senderData?.balance ?? 0;
|
const senderBalance = senderData?.balance ?? 0;
|
||||||
|
|
||||||
if (senderBalance < amount) {
|
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `You only have **${senderBalance}** coins available.`,
|
content: `You only have **${senderBalance}** coins available.`,
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await collection.updateOne(
|
|
||||||
{ userID: senderId },
|
|
||||||
{ $inc: { balance: -amount } },
|
|
||||||
{ upsert: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
await collection.updateOne(
|
await collection.updateOne(
|
||||||
{ userID: target.id },
|
{ userID: target.id },
|
||||||
{ $inc: { balance: amount } },
|
{ $inc: { balance: amount } },
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ module.exports = {
|
|||||||
.setDescription('The reason of the kick')),
|
.setDescription('The reason of the kick')),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
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 user = interaction.options.getUser('target');
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ module.exports = {
|
|||||||
.setDescription('The reason of the mute')),
|
.setDescription('The reason of the mute')),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
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 target = interaction.options.getUser('target');
|
||||||
const duration = interaction.options.getInteger('duration');
|
const duration = interaction.options.getInteger('duration');
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ module.exports = {
|
|||||||
.setRequired(true))),
|
.setRequired(true))),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
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 target = interaction.options.getUser('target');
|
||||||
const role = interaction.options.getRole('role');
|
const role = interaction.options.getRole('role');
|
||||||
const action = interaction.options.getSubcommand();
|
const action = interaction.options.getSubcommand();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ module.exports = {
|
|||||||
option
|
option
|
||||||
.setName('durationms')
|
.setName('durationms')
|
||||||
.setDescription('Duration of the temporary ban (in milliseconds).')
|
.setDescription('Duration of the temporary ban (in milliseconds).')
|
||||||
|
.setMinValue(60_000)
|
||||||
|
.setMaxValue(2_592_000_000)
|
||||||
.setRequired(true))
|
.setRequired(true))
|
||||||
.addStringOption(option =>
|
.addStringOption(option =>
|
||||||
option
|
option
|
||||||
@@ -31,23 +33,41 @@ module.exports = {
|
|||||||
.setMinValue(0).setMaxValue(7)),
|
.setMinValue(0).setMaxValue(7)),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
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 user = interaction.options.getUser('target');
|
||||||
const reason = interaction.options.getString('reason');
|
const reason = interaction.options.getString('reason');
|
||||||
const durationms = interaction.options.getInteger('durationms');
|
const durationms = interaction.options.getInteger('durationms');
|
||||||
const deleteMessages = interaction.options.getInteger('delete-messages');
|
const deleteMessages = interaction.options.getInteger('delete-messages');
|
||||||
|
|
||||||
// If you want to parse time like "1d", "10m", etc., use ms package:
|
if (user.bot) {
|
||||||
let banUntil;
|
|
||||||
try {
|
|
||||||
banUntil = Date.now() + durationms;
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, handle error
|
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Invalid duration format. Please use something like "10m" or "1d".',
|
content: 'You cannot temp-ban a bot account.',
|
||||||
ephemeral: true
|
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
|
// A function to send ephemeral messages
|
||||||
async function sendEphemeral(message) {
|
async function sendEphemeral(message) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const { getGuildSettings } = require('../../services/guildSettings');
|
|||||||
const { recordModerationAction } = require('../../services/moderationRecords');
|
const { recordModerationAction } = require('../../services/moderationRecords');
|
||||||
const { connectToDatabase } = require('../../db');
|
const { connectToDatabase } = require('../../db');
|
||||||
|
|
||||||
|
const MAX_REASON_LENGTH = 500;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
permissionGroup: 'moderation',
|
permissionGroup: 'moderation',
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,6 +33,12 @@ module.exports = {
|
|||||||
ephemeral: true,
|
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({
|
const { warnings, strikeCount } = await addWarning({
|
||||||
guildId: interaction.guildId,
|
guildId: interaction.guildId,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+135
-36
@@ -5,39 +5,40 @@ const axios = require('axios');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
|
const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
|
||||||
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
|
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_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 = {
|
const PERMISSION_BITS = {
|
||||||
ADMINISTRATOR: 0x8n,
|
ADMINISTRATOR: 0x8n,
|
||||||
MANAGE_GUILD: 0x20n,
|
MANAGE_GUILD: 0x20n,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfig = () => {
|
const config = getConfig();
|
||||||
const configPath = path.join(__dirname, 'config.json');
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
return require(configPath);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = loadConfig();
|
const DISCORD_CLIENT_ID = getConfigValue(
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
const DISCORD_CLIENT_ID =
|
'discordClientId',
|
||||||
process.env.DISCORD_CLIENT_ID ||
|
'clientId',
|
||||||
config.discordClientId ||
|
'appId',
|
||||||
config.clientId ||
|
);
|
||||||
config.appId;
|
const DISCORD_CLIENT_SECRET = getConfigValue(
|
||||||
const DISCORD_CLIENT_SECRET =
|
'DISCORD_CLIENT_SECRET',
|
||||||
process.env.DISCORD_CLIENT_SECRET ||
|
'discordClientSecret',
|
||||||
config.discordClientSecret ||
|
'clientSecret',
|
||||||
config.clientSecret;
|
);
|
||||||
const DISCORD_REDIRECT_URI =
|
const DISCORD_REDIRECT_URI = getConfigValue(
|
||||||
process.env.DISCORD_REDIRECT_URI ||
|
'DISCORD_REDIRECT_URI',
|
||||||
config.discordRedirectUri ||
|
'discordRedirectUri',
|
||||||
config.redirectUri;
|
'redirectUri',
|
||||||
const DISCORD_BOT_TOKEN =
|
);
|
||||||
process.env.DISCORD_BOT_TOKEN ||
|
const DISCORD_BOT_TOKEN = getConfigValue(
|
||||||
config.discordBotToken ||
|
'DISCORD_BOT_TOKEN',
|
||||||
config.token;
|
'discordBotToken',
|
||||||
|
'token',
|
||||||
|
);
|
||||||
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
|
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
|
||||||
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
|
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
|
||||||
10,
|
10,
|
||||||
@@ -50,13 +51,17 @@ const SESSION_TTL_MS = Number.parseInt(
|
|||||||
process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs,
|
process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
const TRUST_PROXY =
|
||||||
|
process.env.DASHBOARD_TRUST_PROXY === 'true' ||
|
||||||
|
process.env.DASHBOARD_TRUST_PROXY === '1' ||
|
||||||
|
Boolean(config.trustProxy);
|
||||||
|
|
||||||
console.log('[oauth config]', {
|
console.log('[oauth config]', {
|
||||||
DISCORD_CLIENT_ID_present: Boolean(DISCORD_CLIENT_ID && String(DISCORD_CLIENT_ID).trim()),
|
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_CLIENT_SECRET_present: Boolean(DISCORD_CLIENT_SECRET && String(DISCORD_CLIENT_SECRET).trim()),
|
||||||
DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null,
|
DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null,
|
||||||
configFilePath: path.join(__dirname, 'config.json'),
|
configFilePath: CONFIG_PATH,
|
||||||
configFileExists: fs.existsSync(path.join(__dirname, 'config.json')),
|
configFileExists: fs.existsSync(CONFIG_PATH),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DISCORD_API_BASE = 'https://discord.com/api';
|
const DISCORD_API_BASE = 'https://discord.com/api';
|
||||||
@@ -72,6 +77,61 @@ const botGuildCache = {
|
|||||||
fetchedAt: 0,
|
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 parseCookies = (headerValue) => {
|
||||||
const cookies = {};
|
const cookies = {};
|
||||||
if (!headerValue) {
|
if (!headerValue) {
|
||||||
@@ -115,13 +175,15 @@ const clearCookie = (res, name) => {
|
|||||||
const createSession = (payload) => {
|
const createSession = (payload) => {
|
||||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS);
|
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, {
|
sessionStore.set(sessionId, {
|
||||||
...payload,
|
...payload,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
csrfToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {sessionId, expiresAt};
|
return {sessionId, expiresAt, csrfToken};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSession = (sessionId) => {
|
const getSession = (sessionId) => {
|
||||||
@@ -409,10 +471,34 @@ const ensureManageableGuild = async (req, res, next) => {
|
|||||||
const createDashboardApp = () => {
|
const createDashboardApp = () => {
|
||||||
const app = express();
|
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(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()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
@@ -428,7 +514,7 @@ const createDashboardApp = () => {
|
|||||||
return res.redirect(buildAuthorizationUrl(state));
|
return res.redirect(buildAuthorizationUrl(state));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/discord/callback', async (req, res) => {
|
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
|
||||||
if (!ensureOAuthConfig()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
@@ -467,7 +553,7 @@ const createDashboardApp = () => {
|
|||||||
return res.redirect('/dashboard.html');
|
return res.redirect('/dashboard.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({user});
|
return res.status(200).json({user, csrfToken: session.csrfToken});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Discord OAuth failed', {
|
console.error('Discord OAuth failed', {
|
||||||
message: error.message,
|
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);
|
destroySession(req.sessionId);
|
||||||
clearCookie(res, 'dashboard_session');
|
clearCookie(res, 'dashboard_session');
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
@@ -492,6 +578,7 @@ const createDashboardApp = () => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
expiresAt: req.session.expiresAt,
|
expiresAt: req.session.expiresAt,
|
||||||
|
csrfToken: req.session.csrfToken,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -554,8 +641,12 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/permissions',
|
'/api/guilds/:guildId/permissions',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.body || typeof req.body !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid permissions payload' });
|
||||||
|
}
|
||||||
const permissions = await updateGuildPermissions({
|
const permissions = await updateGuildPermissions({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
permissions: req.body || {},
|
permissions: req.body || {},
|
||||||
@@ -605,6 +696,9 @@ const createDashboardApp = () => {
|
|||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
return res.json({members: []});
|
return res.json({members: []});
|
||||||
}
|
}
|
||||||
|
if (query.length > MAX_MEMBER_QUERY_LENGTH) {
|
||||||
|
return res.status(400).json({ error: 'Query too long' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
|
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
|
||||||
const normalized = (members || [])
|
const normalized = (members || [])
|
||||||
@@ -679,17 +773,21 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const {reason} = req.body || {};
|
const {reason} = req.body || {};
|
||||||
if (!reason) {
|
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
||||||
return res.status(400).json({error: 'Reason is required'});
|
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 {
|
try {
|
||||||
const result = await addWarning({
|
const result = await addWarning({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
moderatorId: req.session.user?.id,
|
moderatorId: req.session.user?.id,
|
||||||
reason,
|
reason: reason.trim(),
|
||||||
});
|
});
|
||||||
const warnings = (result.warnings || []).map((warning) => ({
|
const warnings = (result.warnings || []).map((warning) => ({
|
||||||
...warning,
|
...warning,
|
||||||
@@ -718,6 +816,7 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const warnings = await clearWarnings({
|
const warnings = await clearWarnings({
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
const { MongoClient, ServerApiVersion } = require('mongodb');
|
const { MongoClient, ServerApiVersion } = require('mongodb');
|
||||||
const {mongodb} = require("./config.json");
|
const { getConfigValue } = require('./config');
|
||||||
|
|
||||||
let dbClient;
|
let dbClient;
|
||||||
|
|
||||||
async function connectToDatabase() {
|
async function connectToDatabase() {
|
||||||
if (!dbClient) {
|
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: {
|
serverApi: {
|
||||||
version: ServerApiVersion.v1,
|
version: ServerApiVersion.v1,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { Client, Collection, GatewayIntentBits } = require('discord.js');
|
const { Client, Collection, GatewayIntentBits } = require('discord.js');
|
||||||
const { token } = require('./config.json');
|
const { getConfigValue } = require('./config');
|
||||||
const { loadCommandModules } = require('./loaders/commands');
|
const { loadCommandModules } = require('./loaders/commands');
|
||||||
const { loadEventModules } = require('./loaders/events');
|
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] });
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||||
client.commands = new Collection();
|
client.commands = new Collection();
|
||||||
const commandsPath = path.join(__dirname, 'commands');
|
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);
|
client.login(token);
|
||||||
|
|||||||
+20
-1
@@ -14,11 +14,21 @@ const createFetchError = async (response) => {
|
|||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let csrfToken = null;
|
||||||
|
|
||||||
|
const setCsrfToken = (token) => {
|
||||||
|
csrfToken = typeof token === 'string' ? token : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfToken = () => csrfToken;
|
||||||
|
|
||||||
const fetchJson = async (url, options = {}) => {
|
const fetchJson = async (url, options = {}) => {
|
||||||
|
const method = (options.method || 'GET').toUpperCase();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(csrfToken && method !== 'GET' && method !== 'HEAD' ? {'X-CSRF-Token': csrfToken} : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
@@ -110,6 +120,7 @@ const initIndexPage = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson('/api/me');
|
const data = await fetchJson('/api/me');
|
||||||
|
setCsrfToken(data.csrfToken);
|
||||||
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
||||||
loginButton.hidden = true;
|
loginButton.hidden = true;
|
||||||
dashboardButton.hidden = false;
|
dashboardButton.hidden = false;
|
||||||
@@ -131,7 +142,13 @@ const initIndexPage = async () => {
|
|||||||
|
|
||||||
logoutButton?.addEventListener('click', async () => {
|
logoutButton?.addEventListener('click', async () => {
|
||||||
try {
|
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.';
|
sessionStatus.textContent = 'Logged out successfully.';
|
||||||
loginButton.hidden = false;
|
loginButton.hidden = false;
|
||||||
dashboardButton.hidden = true;
|
dashboardButton.hidden = true;
|
||||||
@@ -151,6 +168,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
window.dashboardApi = {
|
window.dashboardApi = {
|
||||||
fetchJson,
|
fetchJson,
|
||||||
|
setCsrfToken,
|
||||||
|
getCsrfToken,
|
||||||
showToast,
|
showToast,
|
||||||
setErrorBanner,
|
setErrorBanner,
|
||||||
clearErrorBanner,
|
clearErrorBanner,
|
||||||
|
|||||||
+8
-1
@@ -580,6 +580,7 @@ const updateBotStatusControls = () => {
|
|||||||
const loadSession = async () => {
|
const loadSession = async () => {
|
||||||
const data = await fetchDashboardJson('/api/me');
|
const data = await fetchDashboardJson('/api/me');
|
||||||
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
||||||
|
dashboardApi.setCsrfToken(data.csrfToken);
|
||||||
if (data.expiresAt) {
|
if (data.expiresAt) {
|
||||||
const expires = new Date(data.expiresAt);
|
const expires = new Date(data.expiresAt);
|
||||||
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
|
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
|
||||||
@@ -993,7 +994,13 @@ const initDashboard = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements.logoutButton.addEventListener('click', 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 = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user