Files
Discord_bot/dashboardServer.js
T

512 lines
16 KiB
JavaScript

const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const rateLimit = require('express-rate-limit');
const {addWarning, getWarnings} = require('./services/warnings');
const {
getGuildPermissions,
updateGuildPermissions,
sanitizePermissionPayload,
} = require('./services/permissions');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
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 = 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 MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
const SESSION_SECRET = process.env.SESSION_SECRET || config.sessionSecret;
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,
);
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')),
});
const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
const logInfo = (message, meta = {}) => {
console.log(JSON.stringify({level: 'info', message, ...meta}));
};
const logError = (message, meta = {}) => {
console.error(JSON.stringify({level: 'error', message, ...meta}));
};
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 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, {
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('Missing Discord bot token.');
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const parsePermissionBits = (permissionValue) => {
if (!permissionValue) {
return 0n;
}
try {
return BigInt(permissionValue);
} catch (error) {
return 0n;
}
};
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) => {
if (!req.session?.user || !req.session?.accessToken) {
return res.status(401).json({error: 'Unauthorized'});
}
return next();
};
const ensureManageableGuild = async (req, res, next) => {
try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
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) {
logError('Failed to fetch guilds for permission check', {
message: error.message,
status: error.response?.status,
});
return res.status(502).json({error: 'Failed to fetch guilds'});
}
};
const buildCorsOrigins = () => {
if (!DASHBOARD_CLIENT_URL) {
return true;
}
const entries = DASHBOARD_CLIENT_URL.split(',').map((value) => value.trim()).filter(Boolean);
return entries.length ? entries : true;
};
const buildSessionStore = () => {
if (!MONGODB_URI) {
return null;
}
return MongoStore.create({
mongoUrl: MONGODB_URI,
collectionName: 'dashboard_sessions',
ttl: Math.floor(
(Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS) / 1000,
),
});
};
const validatePermissionPayload = (payload = {}) => {
const errors = [];
if (payload.adminRoleIds && !Array.isArray(payload.adminRoleIds)) {
errors.push('adminRoleIds must be an array of role IDs');
}
if (payload.adminUserIds && !Array.isArray(payload.adminUserIds)) {
errors.push('adminUserIds must be an array of user IDs');
}
if (payload.commandPermissions && typeof payload.commandPermissions !== 'object') {
errors.push('commandPermissions must be an object');
}
return errors;
};
const validateWarningReason = (reason) => {
if (typeof reason !== 'string') {
return 'Reason must be a string';
}
const trimmed = reason.trim();
if (!trimmed) {
return 'Reason is required';
}
if (trimmed.length > 500) {
return 'Reason must be 500 characters or fewer';
}
return null;
};
const createDashboardApp = () => {
const app = express();
const isProduction = process.env.NODE_ENV === 'production';
app.set('trust proxy', 1);
app.use(
cors({
origin: buildCorsOrigins(),
credentials: true,
}),
);
app.use(express.json());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
if (!SESSION_SECRET) {
logError('Missing SESSION_SECRET configuration');
}
const sessionStore = buildSessionStore();
if (!sessionStore) {
logError('Missing MONGODB_URI configuration for session store');
}
app.use(
session({
secret: SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: isProduction,
maxAge: Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS,
},
store: sessionStore || undefined,
}),
);
const authLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
app.get('/health', (req, res) => {
return res.json({status: 'ok'});
});
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');
res.cookie('oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
secure: isProduction,
maxAge: 300 * 1000,
});
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;
if (!code || !state || req.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 expiresAt = Date.now() + (tokenData.expires_in || 0) * 1000;
req.session.accessToken = tokenData.access_token;
req.session.refreshToken = tokenData.refresh_token;
req.session.tokenType = tokenData.token_type;
req.session.scope = tokenData.scope;
req.session.expiresAt = expiresAt;
req.session.user = user;
res.clearCookie('oauth_state');
if (DASHBOARD_CLIENT_URL) {
return res.redirect(DASHBOARD_CLIENT_URL);
}
if (req.accepts('html')) {
return res.redirect('/');
}
return res.status(200).json({user});
} catch (error) {
logError('Discord OAuth failed', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
return res.status(502).json({
error: 'Failed to authenticate with Discord',
details: error.response?.data || error.message,
});
}
});
app.post('/auth/logout', ensureSession, (req, res) => {
req.session.destroy((err) => {
if (err) {
logError('Failed to destroy session', {message: err.message});
return res.status(500).json({error: 'Failed to log out'});
}
res.clearCookie('connect.sid');
return res.status(204).send();
});
});
app.get('/api/me', ensureSession, (req, res) => {
return res.json({user: req.session.user});
});
app.get('/api/guilds', ensureSession, async (req, res) => {
try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds});
} catch (error) {
logError('Failed to fetch guilds', {
message: error.message,
status: error.response?.status,
});
return res.status(502).json({error: 'Failed to fetch guilds'});
}
});
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) {
logError('Failed to fetch permissions', {message: error.message});
return res.status(500).json({error: 'Failed to fetch permissions'});
}
},
);
app.patch(
'/api/guilds/:guildId/permissions',
ensureSession,
ensureManageableGuild,
async (req, res) => {
const validationErrors = validatePermissionPayload(req.body || {});
if (validationErrors.length) {
return res.status(400).json({error: 'Invalid permissions payload', details: validationErrors});
}
try {
const sanitizedPermissions = sanitizePermissionPayload(req.body || {});
const permissions = await updateGuildPermissions({
guildId: req.params.guildId,
permissions: sanitizedPermissions,
});
return res.json({permissions});
} catch (error) {
logError('Failed to update permissions', {message: error.message});
return res.status(500).json({error: 'Failed to update permissions'});
}
},
);
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) {
logError('Failed to fetch roles', {
message: error.message,
status: error.response?.status,
});
return res.status(500).json({error: 'Failed to fetch roles'});
}
},
);
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,
});
return res.json({warnings});
} catch (error) {
logError('Failed to fetch warnings', {message: error.message});
return res.status(500).json({error: 'Failed to fetch warnings'});
}
},
);
app.post(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
async (req, res) => {
const {reason} = req.body || {};
const reasonError = validateWarningReason(reason);
if (reasonError) {
return res.status(400).json({error: reasonError});
}
try {
const result = await addWarning({
guildId: req.params.guildId,
userId: req.params.userId,
moderatorId: req.session.user?.id,
reason: reason.trim(),
});
return res.status(201).json(result);
} catch (error) {
logError('Failed to add warning', {message: error.message});
return res.status(500).json({error: 'Failed to add warning'});
}
},
);
return app;
};
if (require.main === module) {
const port = Number.parseInt(process.env.DASHBOARD_PORT || 3000, 10);
const app = createDashboardApp();
app.listen(port, () => {
console.log(`Dashboard API listening on port ${port}`);
});
}
module.exports = {
createDashboardApp,
};