implement changes to commands and templates

This commit is contained in:
2026-04-08 14:20:54 +02:00
parent e90b8b4d85
commit 65a281dbcb
15 changed files with 326 additions and 59 deletions
+12
View File
@@ -90,3 +90,15 @@ Example document stored in the `discord.guild_permissions` collection:
} }
} }
``` ```
---
### **Configuration**
Set secrets via environment variables, or copy `config.example.json` to `config.json` and fill in your own values.
Recommended env vars:
- `DISCORD_BOT_TOKEN`
- `DISCORD_CLIENT_ID`
- `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URI`
- `MONGODB_URI`
+6
View File
@@ -14,6 +14,12 @@ module.exports = {
async execute(interaction) { async execute(interaction) {
const dbClient = await connectToDatabase(); const dbClient = await connectToDatabase();
const message = interaction.options.getString('message'); const message = interaction.options.getString('message');
if (message.length > 500) {
return interaction.reply({
content: 'Message must be 500 characters or fewer.',
ephemeral: true,
});
}
const db = dbClient.db('discord'); const db = dbClient.db('discord');
const collection = db.collection('test'); const collection = db.collection('test');
await collection.insertOne({ userId: interaction.user.id, message: message }); await collection.insertOne({ userId: interaction.user.id, message: message });
+8 -9
View File
@@ -40,22 +40,21 @@ module.exports = {
} }
const senderId = interaction.user.id; const senderId = interaction.user.id;
const senderData = await collection.findOne({ userID: senderId }); const debitResult = await collection.findOneAndUpdate(
const senderBalance = senderData?.balance ?? 0; { userID: senderId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ returnDocument: 'after' }
);
if (senderBalance < amount) { if (!debitResult.value) {
const senderData = await collection.findOne({ userID: senderId });
const senderBalance = senderData?.balance ?? 0;
return interaction.reply({ return interaction.reply({
content: `You only have **${senderBalance}** coins available.`, content: `You only have **${senderBalance}** coins available.`,
ephemeral: true, ephemeral: true,
}); });
} }
await collection.updateOne(
{ userID: senderId },
{ $inc: { balance: -amount } },
{ upsert: true }
);
await collection.updateOne( await collection.updateOne(
{ userID: target.id }, { userID: target.id },
{ $inc: { balance: amount } }, { $inc: { balance: amount } },
+6
View File
@@ -18,6 +18,12 @@ module.exports = {
.setDescription('The reason of the kick')), .setDescription('The reason of the kick')),
category: 'Moderation', category: 'Moderation',
async execute(interaction) { async execute(interaction) {
if (!interaction.guild) {
return interaction.reply({
content: 'This command can only be used inside a server.',
ephemeral: true,
});
}
const user = interaction.options.getUser('target'); const user = interaction.options.getUser('target');
const reason = interaction.options.getString('reason') || 'No reason provided'; const reason = interaction.options.getString('reason') || 'No reason provided';
+6
View File
@@ -25,6 +25,12 @@ module.exports = {
.setDescription('The reason of the mute')), .setDescription('The reason of the mute')),
category: 'Moderation', category: 'Moderation',
async execute(interaction) { async execute(interaction) {
if (!interaction.guild) {
return interaction.reply({
content: 'This command can only be used inside a server.',
ephemeral: true,
});
}
const target = interaction.options.getUser('target'); const target = interaction.options.getUser('target');
const duration = interaction.options.getInteger('duration'); const duration = interaction.options.getInteger('duration');
const reason = interaction.options.getString('reason') || 'No reason provided'; const reason = interaction.options.getString('reason') || 'No reason provided';
+3
View File
@@ -48,6 +48,9 @@ module.exports = {
.setRequired(true))), .setRequired(true))),
category: 'Moderation', category: 'Moderation',
async execute(interaction) { async execute(interaction) {
if (!interaction.guild) {
return interaction.reply({ content: 'This command can only be used inside a server.', ephemeral: true });
}
const target = interaction.options.getUser('target'); const target = interaction.options.getUser('target');
const role = interaction.options.getRole('role'); const role = interaction.options.getRole('role');
const action = interaction.options.getSubcommand(); const action = interaction.options.getSubcommand();
+28 -8
View File
@@ -18,6 +18,8 @@ module.exports = {
option option
.setName('durationms') .setName('durationms')
.setDescription('Duration of the temporary ban (in milliseconds).') .setDescription('Duration of the temporary ban (in milliseconds).')
.setMinValue(60_000)
.setMaxValue(2_592_000_000)
.setRequired(true)) .setRequired(true))
.addStringOption(option => .addStringOption(option =>
option option
@@ -31,23 +33,41 @@ module.exports = {
.setMinValue(0).setMaxValue(7)), .setMinValue(0).setMaxValue(7)),
category: 'Moderation', category: 'Moderation',
async execute(interaction) { async execute(interaction) {
if (!interaction.guild) {
return interaction.reply({
content: 'This command can only be used inside a server.',
ephemeral: true,
});
}
const user = interaction.options.getUser('target'); const user = interaction.options.getUser('target');
const reason = interaction.options.getString('reason'); const reason = interaction.options.getString('reason');
const durationms = interaction.options.getInteger('durationms'); const durationms = interaction.options.getInteger('durationms');
const deleteMessages = interaction.options.getInteger('delete-messages'); const deleteMessages = interaction.options.getInteger('delete-messages');
// If you want to parse time like "1d", "10m", etc., use ms package: if (user.bot) {
let banUntil;
try {
banUntil = Date.now() + durationms;
} catch (e) {
// If parsing fails, handle error
return interaction.reply({ return interaction.reply({
content: 'Invalid duration format. Please use something like "10m" or "1d".', content: 'You cannot temp-ban a bot account.',
ephemeral: true ephemeral: true,
}); });
} }
if (user.id === interaction.user.id) {
return interaction.reply({
content: 'You cannot temp-ban yourself.',
ephemeral: true,
});
}
// If you want to parse time like "1d", "10m", etc., use ms package:
if (!Number.isFinite(durationms) || durationms <= 0) {
return interaction.reply({
content: 'Invalid duration. Provide a positive number of milliseconds.',
ephemeral: true,
});
}
const banUntil = Date.now() + durationms;
// A function to send ephemeral messages // A function to send ephemeral messages
async function sendEphemeral(message) { async function sendEphemeral(message) {
+8
View File
@@ -4,6 +4,8 @@ const { getGuildSettings } = require('../../services/guildSettings');
const { recordModerationAction } = require('../../services/moderationRecords'); const { recordModerationAction } = require('../../services/moderationRecords');
const { connectToDatabase } = require('../../db'); const { connectToDatabase } = require('../../db');
const MAX_REASON_LENGTH = 500;
module.exports = { module.exports = {
permissionGroup: 'moderation', permissionGroup: 'moderation',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -31,6 +33,12 @@ module.exports = {
ephemeral: true, ephemeral: true,
}); });
} }
if (reason.length > MAX_REASON_LENGTH) {
return interaction.reply({
content: `Reason must be ${MAX_REASON_LENGTH} characters or fewer.`,
ephemeral: true,
});
}
const { warnings, strikeCount } = await addWarning({ const { warnings, strikeCount } = await addWarning({
guildId: interaction.guildId, guildId: interaction.guildId,
+11
View File
@@ -0,0 +1,11 @@
{
"token": "YOUR_DISCORD_BOT_TOKEN",
"clientId": "YOUR_DISCORD_CLIENT_ID",
"clientSecret": "YOUR_DISCORD_CLIENT_SECRET",
"mongodb": "mongodb://127.0.0.1:27017/botdb",
"redirectUri": "http://localhost:3000/auth/discord/callback",
"dashboardInvitePermissions": "0",
"dashboardSessionTtlMs": "86400000",
"dashboardClientUrl": "http://localhost:3000",
"trustProxy": false
}
+52
View File
@@ -0,0 +1,52 @@
const fs = require('node:fs');
const path = require('node:path');
const CONFIG_PATH = path.join(__dirname, 'config.json');
let cachedConfig = null;
const loadConfig = () => {
if (cachedConfig) {
return cachedConfig;
}
if (!fs.existsSync(CONFIG_PATH)) {
cachedConfig = {};
return cachedConfig;
}
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(raw);
cachedConfig = parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
console.error('Failed to parse config.json', { message: error.message });
cachedConfig = {};
}
return cachedConfig;
};
const getConfig = () => loadConfig();
const getConfigValue = (envKey, ...configKeys) => {
const envValue = process.env[envKey];
if (typeof envValue === 'string' && envValue.trim() !== '') {
return envValue.trim();
}
const config = loadConfig();
for (const key of configKeys) {
const value = config?.[key];
if (typeof value === 'string' && value.trim() !== '') {
return value.trim();
}
}
return undefined;
};
module.exports = {
CONFIG_PATH,
getConfig,
getConfigValue,
};
+135 -36
View File
@@ -5,39 +5,40 @@ const axios = require('axios');
const express = require('express'); const express = require('express');
const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
const { CONFIG_PATH, getConfig, getConfigValue } = require('./config');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; 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 = { const PERMISSION_BITS = {
ADMINISTRATOR: 0x8n, ADMINISTRATOR: 0x8n,
MANAGE_GUILD: 0x20n, MANAGE_GUILD: 0x20n,
}; };
const loadConfig = () => { const config = getConfig();
const configPath = path.join(__dirname, 'config.json');
if (fs.existsSync(configPath)) {
return require(configPath);
}
return {};
};
const config = loadConfig(); const DISCORD_CLIENT_ID = getConfigValue(
'DISCORD_CLIENT_ID',
const DISCORD_CLIENT_ID = 'discordClientId',
process.env.DISCORD_CLIENT_ID || 'clientId',
config.discordClientId || 'appId',
config.clientId || );
config.appId; const DISCORD_CLIENT_SECRET = getConfigValue(
const DISCORD_CLIENT_SECRET = 'DISCORD_CLIENT_SECRET',
process.env.DISCORD_CLIENT_SECRET || 'discordClientSecret',
config.discordClientSecret || 'clientSecret',
config.clientSecret; );
const DISCORD_REDIRECT_URI = const DISCORD_REDIRECT_URI = getConfigValue(
process.env.DISCORD_REDIRECT_URI || 'DISCORD_REDIRECT_URI',
config.discordRedirectUri || 'discordRedirectUri',
config.redirectUri; 'redirectUri',
const DISCORD_BOT_TOKEN = );
process.env.DISCORD_BOT_TOKEN || const DISCORD_BOT_TOKEN = getConfigValue(
config.discordBotToken || 'DISCORD_BOT_TOKEN',
config.token; 'discordBotToken',
'token',
);
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt( const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0', process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
10, 10,
@@ -50,13 +51,17 @@ const SESSION_TTL_MS = Number.parseInt(
process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs, process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs,
10, 10,
); );
const TRUST_PROXY =
process.env.DASHBOARD_TRUST_PROXY === 'true' ||
process.env.DASHBOARD_TRUST_PROXY === '1' ||
Boolean(config.trustProxy);
console.log('[oauth config]', { console.log('[oauth config]', {
DISCORD_CLIENT_ID_present: Boolean(DISCORD_CLIENT_ID && String(DISCORD_CLIENT_ID).trim()), 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_CLIENT_SECRET_present: Boolean(DISCORD_CLIENT_SECRET && String(DISCORD_CLIENT_SECRET).trim()),
DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null, DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null,
configFilePath: path.join(__dirname, 'config.json'), configFilePath: CONFIG_PATH,
configFileExists: fs.existsSync(path.join(__dirname, 'config.json')), configFileExists: fs.existsSync(CONFIG_PATH),
}); });
const DISCORD_API_BASE = 'https://discord.com/api'; const DISCORD_API_BASE = 'https://discord.com/api';
@@ -72,6 +77,61 @@ const botGuildCache = {
fetchedAt: 0, 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 parseCookies = (headerValue) => {
const cookies = {}; const cookies = {};
if (!headerValue) { if (!headerValue) {
@@ -115,13 +175,15 @@ const clearCookie = (res, name) => {
const createSession = (payload) => { const createSession = (payload) => {
const sessionId = crypto.randomBytes(32).toString('hex'); const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS); 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, { sessionStore.set(sessionId, {
...payload, ...payload,
expiresAt, expiresAt,
csrfToken,
}); });
return {sessionId, expiresAt}; return {sessionId, expiresAt, csrfToken};
}; };
const getSession = (sessionId) => { const getSession = (sessionId) => {
@@ -409,10 +471,34 @@ const ensureManageableGuild = async (req, res, next) => {
const createDashboardApp = () => { const createDashboardApp = () => {
const app = express(); const app = express();
app.use(express.json()); 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(express.static(path.join(__dirname, 'public')));
app.use(createRateLimiter({
windowMs: 60_000,
max: 120,
keyFn: (req) => req.ip || 'unknown',
}));
app.get('/auth/discord', (req, res) => { 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()) { if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'}); return res.status(500).json({error: 'Missing Discord OAuth configuration'});
} }
@@ -428,7 +514,7 @@ const createDashboardApp = () => {
return res.redirect(buildAuthorizationUrl(state)); return res.redirect(buildAuthorizationUrl(state));
}); });
app.get('/auth/discord/callback', async (req, res) => { app.get('/auth/discord/callback', authLimiter, async (req, res) => {
if (!ensureOAuthConfig()) { if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'}); return res.status(500).json({error: 'Missing Discord OAuth configuration'});
} }
@@ -467,7 +553,7 @@ const createDashboardApp = () => {
return res.redirect('/dashboard.html'); return res.redirect('/dashboard.html');
} }
return res.status(200).json({user}); return res.status(200).json({user, csrfToken: session.csrfToken});
} catch (error) { } catch (error) {
console.error('Discord OAuth failed', { console.error('Discord OAuth failed', {
message: error.message, message: error.message,
@@ -482,7 +568,7 @@ const createDashboardApp = () => {
} }
}); });
app.post('/auth/logout', ensureSession, (req, res) => { app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => {
destroySession(req.sessionId); destroySession(req.sessionId);
clearCookie(res, 'dashboard_session'); clearCookie(res, 'dashboard_session');
return res.status(204).send(); return res.status(204).send();
@@ -492,6 +578,7 @@ const createDashboardApp = () => {
return res.json({ return res.json({
user: req.session.user, user: req.session.user,
expiresAt: req.session.expiresAt, expiresAt: req.session.expiresAt,
csrfToken: req.session.csrfToken,
}); });
}); });
@@ -554,8 +641,12 @@ const createDashboardApp = () => {
'/api/guilds/:guildId/permissions', '/api/guilds/:guildId/permissions',
ensureSession, ensureSession,
ensureManageableGuild, ensureManageableGuild,
ensureCsrf,
async (req, res) => { async (req, res) => {
try { try {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'Invalid permissions payload' });
}
const permissions = await updateGuildPermissions({ const permissions = await updateGuildPermissions({
guildId: req.params.guildId, guildId: req.params.guildId,
permissions: req.body || {}, permissions: req.body || {},
@@ -605,6 +696,9 @@ const createDashboardApp = () => {
if (query.length < 2) { if (query.length < 2) {
return res.json({members: []}); return res.json({members: []});
} }
if (query.length > MAX_MEMBER_QUERY_LENGTH) {
return res.status(400).json({ error: 'Query too long' });
}
try { try {
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query); const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
const normalized = (members || []) const normalized = (members || [])
@@ -679,17 +773,21 @@ const createDashboardApp = () => {
'/api/guilds/:guildId/warnings/:userId', '/api/guilds/:guildId/warnings/:userId',
ensureSession, ensureSession,
ensureManageableGuild, ensureManageableGuild,
ensureCsrf,
async (req, res) => { async (req, res) => {
const {reason} = req.body || {}; const {reason} = req.body || {};
if (!reason) { if (typeof reason !== 'string' || reason.trim().length === 0) {
return res.status(400).json({error: 'Reason is required'}); 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 { try {
const result = await addWarning({ const result = await addWarning({
guildId: req.params.guildId, guildId: req.params.guildId,
userId: req.params.userId, userId: req.params.userId,
moderatorId: req.session.user?.id, moderatorId: req.session.user?.id,
reason, reason: reason.trim(),
}); });
const warnings = (result.warnings || []).map((warning) => ({ const warnings = (result.warnings || []).map((warning) => ({
...warning, ...warning,
@@ -718,6 +816,7 @@ const createDashboardApp = () => {
'/api/guilds/:guildId/warnings/:userId', '/api/guilds/:guildId/warnings/:userId',
ensureSession, ensureSession,
ensureManageableGuild, ensureManageableGuild,
ensureCsrf,
async (req, res) => { async (req, res) => {
try { try {
const warnings = await clearWarnings({ const warnings = await clearWarnings({
+8 -3
View File
@@ -1,11 +1,16 @@
const {MongoClient, ServerApiVersion} = require('mongodb'); const { MongoClient, ServerApiVersion } = require('mongodb');
const {mongodb} = require("./config.json"); const { getConfigValue } = require('./config');
let dbClient; let dbClient;
async function connectToDatabase() { async function connectToDatabase() {
if (!dbClient) { if (!dbClient) {
dbClient = new MongoClient(mongodb, { const mongodbUri = getConfigValue('MONGODB_URI', 'mongodb', 'mongodbUri');
if (!mongodbUri) {
throw new Error('Missing MongoDB connection string. Set MONGODB_URI or config.json mongodb.');
}
dbClient = new MongoClient(mongodbUri, {
serverApi: { serverApi: {
version: ServerApiVersion.v1, version: ServerApiVersion.v1,
strict: true, strict: true,
+15 -1
View File
@@ -1,8 +1,16 @@
const path = require('node:path'); const path = require('node:path');
const { Client, Collection, GatewayIntentBits } = require('discord.js'); const { Client, Collection, GatewayIntentBits } = require('discord.js');
const { token } = require('./config.json'); const { getConfigValue } = require('./config');
const { loadCommandModules } = require('./loaders/commands'); const { loadCommandModules } = require('./loaders/commands');
const { loadEventModules } = require('./loaders/events'); const { loadEventModules } = require('./loaders/events');
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
const client = new Client({ intents: [GatewayIntentBits.Guilds] }); const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.commands = new Collection(); client.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands'); const commandsPath = path.join(__dirname, 'commands');
@@ -26,4 +34,10 @@ for (const { event } of eventModules) {
} }
} }
const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken');
if (!token) {
console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.');
process.exit(1);
}
client.login(token); client.login(token);
+20 -1
View File
@@ -14,11 +14,21 @@ const createFetchError = async (response) => {
return error; return error;
}; };
let csrfToken = null;
const setCsrfToken = (token) => {
csrfToken = typeof token === 'string' ? token : null;
};
const getCsrfToken = () => csrfToken;
const fetchJson = async (url, options = {}) => { const fetchJson = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(csrfToken && method !== 'GET' && method !== 'HEAD' ? {'X-CSRF-Token': csrfToken} : {}),
...(options.headers || {}), ...(options.headers || {}),
}, },
...options, ...options,
@@ -110,6 +120,7 @@ const initIndexPage = async () => {
try { try {
const data = await fetchJson('/api/me'); const data = await fetchJson('/api/me');
setCsrfToken(data.csrfToken);
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`; sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
loginButton.hidden = true; loginButton.hidden = true;
dashboardButton.hidden = false; dashboardButton.hidden = false;
@@ -131,7 +142,13 @@ const initIndexPage = async () => {
logoutButton?.addEventListener('click', async () => { logoutButton?.addEventListener('click', async () => {
try { try {
await fetch('/auth/logout', {method: 'POST', credentials: 'include'}); await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
...(csrfToken ? {'X-CSRF-Token': csrfToken} : {}),
},
});
sessionStatus.textContent = 'Logged out successfully.'; sessionStatus.textContent = 'Logged out successfully.';
loginButton.hidden = false; loginButton.hidden = false;
dashboardButton.hidden = true; dashboardButton.hidden = true;
@@ -151,6 +168,8 @@ document.addEventListener('DOMContentLoaded', () => {
window.dashboardApi = { window.dashboardApi = {
fetchJson, fetchJson,
setCsrfToken,
getCsrfToken,
showToast, showToast,
setErrorBanner, setErrorBanner,
clearErrorBanner, clearErrorBanner,
+8 -1
View File
@@ -580,6 +580,7 @@ const updateBotStatusControls = () => {
const loadSession = async () => { const loadSession = async () => {
const data = await fetchDashboardJson('/api/me'); const data = await fetchDashboardJson('/api/me');
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user); elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
dashboardApi.setCsrfToken(data.csrfToken);
if (data.expiresAt) { if (data.expiresAt) {
const expires = new Date(data.expiresAt); const expires = new Date(data.expiresAt);
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`; elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
@@ -993,7 +994,13 @@ const initDashboard = async () => {
} }
elements.logoutButton.addEventListener('click', async () => { elements.logoutButton.addEventListener('click', async () => {
await fetch('/auth/logout', {method: 'POST', credentials: 'include'}); await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
...(dashboardApi.getCsrfToken() ? {'X-CSRF-Token': dashboardApi.getCsrfToken()} : {}),
},
});
window.location.href = '/'; window.location.href = '/';
}); });