Files
Discord_bot/dashboardServer.js
T
Frederik Spirgi 0a7d056fc8 Update version
2026-09-28 09:38:52 +02:00

862 lines
27 KiB
JavaScript

const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
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 config = getConfig();
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,
);
const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl ||
config.clientUrl;
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: CONFIG_PATH,
configFileExists: fs.existsSync(CONFIG_PATH),
});
const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
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.';
const sessionStore = new Map();
const botGuildCache = {
guildIds: null,
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) {
return cookies;
}
headerValue.split(';').forEach((cookie) => {
const [rawName, ...rest] = cookie.trim().split('=');
if (!rawName) {
return;
}
cookies[rawName] = decodeURIComponent(rest.join('='));
});
return cookies;
};
const setCookie = (res, name, value, options = {}) => {
const directives = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge) {
directives.push(`Max-Age=${options.maxAge}`);
}
directives.push(`Path=${options.path || '/'}`);
if (options.httpOnly !== false) {
directives.push('HttpOnly');
}
if (options.sameSite) {
directives.push(`SameSite=${options.sameSite}`);
}
if (options.secure) {
directives.push('Secure');
}
res.append('Set-Cookie', directives.join('; '));
};
const clearCookie = (res, name) => {
setCookie(res, name, '', {maxAge: 0});
};
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, csrfToken};
};
const getSession = (sessionId) => {
const session = sessionStore.get(sessionId);
if (!session) {
return null;
}
if (session.expiresAt <= Date.now()) {
sessionStore.delete(sessionId);
return null;
}
return session;
};
const destroySession = (sessionId) => {
sessionStore.delete(sessionId);
};
const ensureOAuthConfig = () => {
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) {
return null;
}
return {
clientId: DISCORD_CLIENT_ID,
clientSecret: DISCORD_CLIENT_SECRET,
redirectUri: DISCORD_REDIRECT_URI,
};
};
const getDiscordStatusCode = (error) => {
const status = error?.response?.status;
return typeof status === 'number' ? status : null;
};
const sendDiscordApiError = (res, error, fallbackMessage) => {
const status = getDiscordStatusCode(error);
if (status === 401 || status === 403) {
return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
}
if (status === 429) {
return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
}
return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
};
const buildAuthorizationUrl = (state) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
response_type: 'code',
scope: 'identify guilds',
state,
prompt: 'consent',
});
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const fetchDiscordAccessToken = async (code) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: DISCORD_REDIRECT_URI,
});
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
};
const fetchDiscordUser = async (accessToken) => {
const response = await axios.get(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
};
const fetchDiscordUserGuilds = async (accessToken) => {
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
};
const fetchDiscordGuildRoles = async (guildId) => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const fetchDiscordGuildMembersSearch = async (guildId, query) => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/members/search`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
params: {
query,
limit: 20,
},
});
return response.data;
};
const fetchDiscordBotGuilds = async () => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const getCachedBotGuildIds = () => {
if (!Array.isArray(botGuildCache.guildIds)) {
return null;
}
if (!botGuildCache.fetchedAt) {
return null;
}
if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
return null;
}
return botGuildCache.guildIds;
};
const setCachedBotGuildIds = (guildIds) => {
botGuildCache.guildIds = guildIds;
botGuildCache.fetchedAt = Date.now();
};
const buildInviteUrl = (guildId) => {
if (!DISCORD_CLIENT_ID) {
return null;
}
const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
scope: 'bot applications.commands',
permissions: String(permissions),
disable_guild_select: 'true',
});
if (guildId) {
params.set('guild_id', guildId);
}
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const cacheSessionGuilds = (session, guilds) => {
session.guilds = guilds;
session.guildsFetchedAt = Date.now();
};
const getCachedGuilds = (session) => {
if (!Array.isArray(session.guilds)) {
return null;
}
if (!session.guildsFetchedAt) {
return null;
}
if (Date.now() - session.guildsFetchedAt > GUILD_CACHE_TTL_MS) {
return null;
}
return session.guilds;
};
const parsePermissionBits = (permissionValue) => {
if (!permissionValue) {
return 0n;
}
try {
return BigInt(permissionValue);
} catch (error) {
return 0n;
}
};
const isHttpsRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https') {
return true;
}
return Boolean(config.cookieSecure);
};
const loadCommands = () => {
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,
});
}
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) => {
const permissions = parsePermissionBits(guild.permissions);
return (
(permissions & PERMISSION_BITS.ADMINISTRATOR) === PERMISSION_BITS.ADMINISTRATOR ||
(permissions & PERMISSION_BITS.MANAGE_GUILD) === PERMISSION_BITS.MANAGE_GUILD
);
};
const ensureSession = (req, res, next) => {
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies.dashboard_session;
if (!sessionId) {
return res.status(401).json({error: 'Unauthorized'});
}
const session = getSession(sessionId);
if (!session) {
return res.status(401).json({error: 'Unauthorized'});
}
req.session = session;
req.sessionId = sessionId;
return next();
};
const ensureManageableGuild = async (req, res, next) => {
try {
let guilds = getCachedGuilds(req.session);
if (!guilds) {
guilds = await fetchDiscordUserGuilds(req.session.accessToken);
cacheSessionGuilds(req.session, guilds);
}
const guild = guilds.find((entry) => entry.id === req.params.guildId);
if (!guild) {
return res.status(404).json({error: 'Guild not found'});
}
if (!canManageGuild(guild)) {
return res.status(403).json({error: 'Forbidden'});
}
req.guild = guild;
return next();
} catch (error) {
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
};
const createDashboardApp = () => {
const app = express();
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',
}));
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'});
}
const state = crypto.randomBytes(16).toString('hex');
setCookie(res, 'oauth_state', state, {
httpOnly: true,
sameSite: 'Lax',
secure: isHttpsRequest(req),
maxAge: 300,
});
return res.redirect(buildAuthorizationUrl(state));
});
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
}
const {code, state} = req.query;
const cookies = parseCookies(req.headers.cookie);
if (!code || !state || cookies.oauth_state !== state) {
return res.status(400).json({error: 'Invalid OAuth state'});
}
try {
const tokenData = await fetchDiscordAccessToken(code);
const user = await fetchDiscordUser(tokenData.access_token);
const session = createSession({
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type,
scope: tokenData.scope,
user,
});
setCookie(res, 'dashboard_session', session.sessionId, {
httpOnly: true,
sameSite: 'Lax',
secure: isHttpsRequest(req),
maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
});
clearCookie(res, 'oauth_state');
if (DASHBOARD_CLIENT_URL) {
return res.redirect(DASHBOARD_CLIENT_URL);
}
if (req.accepts('html')) {
return res.redirect('/dashboard.html');
}
return res.status(200).json({user, csrfToken: session.csrfToken});
} catch (error) {
console.error('Discord OAuth failed', {
message: error.message,
status: error.response?.status,
data: error.response?.data?.error || error.response?.data?.error_description,
});
return res.status(502).json({
error: 'Failed to authenticate with Discord',
details: error.response?.data?.error_description || error.message,
});
}
});
app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => {
destroySession(req.sessionId);
clearCookie(res, 'dashboard_session');
return res.status(204).send();
});
app.get('/api/me', ensureSession, (req, res) => {
return res.json({
user: req.session.user,
expiresAt: req.session.expiresAt,
csrfToken: req.session.csrfToken,
});
});
app.get('/api/guilds', ensureSession, async (req, res) => {
try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
cacheSessionGuilds(req.session, guilds);
const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds});
} catch (error) {
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
});
app.get('/api/bot/guilds', ensureSession, async (req, res) => {
try {
const cached = getCachedBotGuildIds();
if (cached) {
return res.json({guildIds: cached});
}
const guilds = await fetchDiscordBotGuilds();
const guildIds = Array.isArray(guilds) ? guilds.map((guild) => guild.id) : [];
setCachedBotGuildIds(guildIds);
return res.json({guildIds});
} catch (error) {
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch bot guilds');
}
});
app.get('/api/invite-url', ensureSession, (req, res) => {
if (!DISCORD_CLIENT_ID) {
return res.status(500).json({error: 'MISSING_CLIENT_ID', message: 'Discord client ID not configured.'});
}
const inviteUrl = buildInviteUrl(req.query.guildId);
return res.json({inviteUrl});
});
app.get(
'/api/guilds/:guildId/permissions',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions});
} catch (error) {
console.error('Failed to fetch guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
app.patch(
'/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 || {},
});
return res.json({permissions});
} catch (error) {
console.error('Failed to update guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
app.get(
'/api/guilds/:guildId/roles',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch roles');
}
},
);
app.get(
'/api/guilds/:guildId/members',
ensureSession,
ensureManageableGuild,
async (req, res) => {
const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
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 || [])
.map((member) => {
const user = member.user || {};
if (!user.id) {
return null;
}
return {
id: user.id,
user: {
id: user.id,
username: user.username || '',
discriminator: user.discriminator || '',
global_name: user.global_name || '',
avatar: user.avatar || '',
},
nick: member.nick || '',
};
})
.filter(Boolean);
return res.json({members: normalized});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to search guild members');
}
},
);
app.get(
'/api/guilds/:guildId/commands',
ensureSession,
ensureManageableGuild,
(req, res) => {
const commands = loadCommands();
return res.json({commands});
},
);
app.get(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const warnings = await getWarnings({
guildId: req.params.guildId,
userId: req.params.userId,
});
const normalizedWarnings = warnings.map((warning) => ({
...warning,
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
}));
return res.json({warnings: normalizedWarnings});
} catch (error) {
return res.status(500).json({error: 'Failed to fetch warnings'});
}
},
);
app.post(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
ensureCsrf,
async (req, res) => {
const {reason} = req.body || {};
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.trim(),
});
const warnings = (result.warnings || []).map((warning) => ({
...warning,
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
}));
const warningEntry = result.warningEntry
? {
...result.warningEntry,
createdAt: result.warningEntry.createdAt
? new Date(result.warningEntry.createdAt).toISOString()
: null,
}
: null;
return res.status(201).json({
...result,
warnings,
warningEntry,
});
} catch (error) {
return res.status(500).json({error: 'Failed to add warning'});
}
},
);
app.delete(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
ensureCsrf,
async (req, res) => {
try {
const warnings = await clearWarnings({
guildId: req.params.guildId,
userId: req.params.userId,
});
return res.json({warnings});
} catch (error) {
return res.status(500).json({error: 'Failed to clear warnings'});
}
},
);
return app;
};
if (require.main === module) {
const port = Number.parseInt(process.env.DASHBOARD_PORT || 47823, 10);
const app = createDashboardApp();
app.listen(port, () => {
console.log(`Dashboard API listening on port ${port}`);
});
}
module.exports = {
createDashboardApp,
};