Merge pull request #10 from Fretuks/create-backend-for-discord-bot-dashboard
Add dashboard backend endpoints for Discord OAuth and guild management
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user