Add dashboard backend endpoints

This commit is contained in:
2026-01-27 12:00:08 +01:00
parent 23a1e41933
commit 01f7289fb5
2 changed files with 459 additions and 0 deletions
+437
View File
@@ -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,
};