diff --git a/dashboardServer.js b/dashboardServer.js new file mode 100644 index 0000000..401b816 --- /dev/null +++ b/dashboardServer.js @@ -0,0 +1,437 @@ +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 } = require('./services/warnings'); +const { getGuildPermissions, updateGuildPermissions } = 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 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 DISCORD_API_BASE = 'https://discord.com/api'; + +const sessionStore = new Map(); + +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); + + sessionStore.set(sessionId, { + ...payload, + expiresAt, + }); + + return { sessionId, expiresAt }; +}; + +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 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_API_BASE}/oauth2/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) => { + 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 { + 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) { + return res.status(502).json({ error: 'Failed to fetch guilds' }); + } +}; + +const createDashboardApp = () => { + const app = express(); + + app.use(express.json()); + + app.get('/auth/discord', (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: req.secure, + maxAge: 300, + }); + + return res.redirect(buildAuthorizationUrl(state)); + }); + + app.get('/auth/discord/callback', 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 { sessionId, expiresAt } = createSession({ + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + tokenType: tokenData.token_type, + scope: tokenData.scope, + user, + expiresAt, + }); + + setCookie(res, 'dashboard_session', sessionId, { + httpOnly: true, + sameSite: 'Lax', + secure: req.secure, + maxAge: Math.floor((expiresAt - Date.now()) / 1000), + }); + clearCookie(res, 'oauth_state'); + + if (DASHBOARD_CLIENT_URL) { + return res.redirect(DASHBOARD_CLIENT_URL); + } + + return res.status(200).json({ user }); + } catch (error) { + return res.status(502).json({ error: 'Failed to authenticate with Discord' }); + } + }); + + app.post('/auth/logout', ensureSession, (req, res) => { + destroySession(req.sessionId); + clearCookie(res, 'dashboard_session'); + return res.status(204).send(); + }); + + 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) { + 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) { + return res.status(500).json({ error: 'Failed to fetch permissions' }); + } + }, + ); + + app.patch( + '/api/guilds/:guildId/permissions', + ensureSession, + ensureManageableGuild, + async (req, res) => { + try { + const permissions = await updateGuildPermissions({ + guildId: req.params.guildId, + permissions: req.body || {}, + }); + return res.json({ permissions }); + } catch (error) { + 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) { + 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) { + 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 || {}; + if (!reason) { + return res.status(400).json({ error: 'Reason is required' }); + } + + try { + const result = await addWarning({ + guildId: req.params.guildId, + userId: req.params.userId, + moderatorId: req.session.user?.id, + reason, + }); + return res.status(201).json(result); + } catch (error) { + 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, +}; diff --git a/services/permissions.js b/services/permissions.js index a443548..d794fc5 100644 --- a/services/permissions.js +++ b/services/permissions.js @@ -26,6 +26,27 @@ const getGuildPermissions = async (guildId) => { return normalizePermissionConfig(config); }; +const updateGuildPermissions = async ({ guildId, permissions }) => { + if (!guildId) { + throw new Error('Guild ID is required'); + } + + const dbClient = await connectToDatabase(); + const db = dbClient.db('discord'); + const collection = db.collection('guild_permissions'); + + const normalizedPermissions = normalizePermissionConfig(permissions); + const payload = { + guildId, + ...normalizedPermissions, + updatedAt: new Date(), + }; + + await collection.updateOne({ guildId }, { $set: payload }, { upsert: true }); + + return payload; +}; + const isCommandAllowed = async ({ guildId, userId, @@ -69,5 +90,6 @@ const isCommandAllowed = async ({ module.exports = { getGuildPermissions, + updateGuildPermissions, isCommandAllowed, };