implement changes to commands and templates

This commit is contained in:
2026-04-08 14:20:54 +02:00
parent e90b8b4d85
commit 65a281dbcb
15 changed files with 326 additions and 59 deletions
+135 -36
View File
@@ -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({