security hardening
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
const { Client, GatewayIntentBits, Partials } = require('discord.js');
|
||||
const express = require('express');
|
||||
const { connectMongoDB } = require('./db-connection');
|
||||
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
||||
const { CONFIG } = require('./config');
|
||||
const { mongoose } = require('./db-connection');
|
||||
|
||||
@@ -31,15 +31,33 @@ const { getNextTicketNumber } = require('./services/tickets');
|
||||
const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
|
||||
|
||||
let gmailPollInterval = null;
|
||||
// Track all background setInterval handles so shutdown can clear them.
|
||||
const activeIntervals = new Set();
|
||||
function trackInterval(handle) {
|
||||
if (handle) activeIntervals.add(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Gmail poll interval at runtime.
|
||||
* @param {number} ms - new interval in milliseconds
|
||||
*/
|
||||
function setGmailPollInterval(ms) {
|
||||
if (gmailPollInterval) clearInterval(gmailPollInterval);
|
||||
if (gmailPollInterval) {
|
||||
clearInterval(gmailPollInterval);
|
||||
activeIntervals.delete(gmailPollInterval);
|
||||
}
|
||||
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
|
||||
gmailPollInterval = setInterval(() => poll(client), ms);
|
||||
activeIntervals.add(gmailPollInterval);
|
||||
}
|
||||
|
||||
function clearGmailPollInterval() {
|
||||
if (gmailPollInterval) {
|
||||
clearInterval(gmailPollInterval);
|
||||
activeIntervals.delete(gmailPollInterval);
|
||||
gmailPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- VALIDATE CONFIG ---
|
||||
@@ -71,9 +89,28 @@ const client = new Client({
|
||||
});
|
||||
|
||||
// --- EVENT: interactionCreate ---
|
||||
async function safeReplyError(interaction) {
|
||||
const payload = { content: 'Something went wrong.', ephemeral: true };
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.followUp(payload).catch(() => {});
|
||||
} else {
|
||||
await interaction.reply(payload).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function runHandler(name, interaction, fn) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
console.error(`${name} error:`, err);
|
||||
logError(name, err instanceof Error ? err : new Error(String(err)), null, client).catch(() => {});
|
||||
await safeReplyError(interaction);
|
||||
}
|
||||
}
|
||||
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) {
|
||||
const handled = await handleSendAccountInfoToChannel(interaction);
|
||||
const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
@@ -83,6 +120,7 @@ client.on('interactionCreate', async interaction => {
|
||||
if (handled) return;
|
||||
} catch (err) {
|
||||
console.error('Setup button error:', err);
|
||||
logError('handleSetupButton', err, null, client).catch(() => {});
|
||||
await interaction.reply({
|
||||
content: `Setup error: ${err.message}. Try \`/setup\` again.`,
|
||||
ephemeral: true
|
||||
@@ -92,11 +130,11 @@ client.on('interactionCreate', async interaction => {
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
return handleButton(interaction);
|
||||
return runHandler('handleButton', interaction, () => handleButton(interaction));
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
|
||||
const handled = await handleSetupModal(interaction);
|
||||
const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
@@ -109,9 +147,10 @@ client.on('interactionCreate', async interaction => {
|
||||
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
await StaffSignature.findOneAndUpdate(
|
||||
{ userId: interaction.user.id },
|
||||
{
|
||||
{ userId: interaction.user.id, guildId: interaction.guildId },
|
||||
{
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId,
|
||||
valediction,
|
||||
displayName,
|
||||
tagline,
|
||||
@@ -135,24 +174,24 @@ client.on('interactionCreate', async interaction => {
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && ['ticket_modal', 'ticket_modal_thread', 'ticket_modal_channel'].includes(interaction.customId)) {
|
||||
return handleTicketModal(interaction);
|
||||
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
|
||||
}
|
||||
|
||||
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
|
||||
const handled = await handleSetupSelect(interaction);
|
||||
const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return handleCommand(interaction);
|
||||
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
|
||||
}
|
||||
|
||||
if (interaction.isMessageContextMenuCommand() || interaction.isUserContextMenuCommand()) {
|
||||
return handleContextMenu(interaction);
|
||||
return runHandler('handleContextMenu', interaction, () => handleContextMenu(interaction));
|
||||
}
|
||||
|
||||
if (interaction.isAutocomplete()) {
|
||||
return handleAutocomplete(interaction);
|
||||
return runHandler('handleAutocomplete', interaction, () => handleAutocomplete(interaction));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -184,6 +223,11 @@ client.once('ready', async () => {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
}
|
||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
});
|
||||
appReady = true;
|
||||
console.log(`Broccolini Bot active on port ${CONFIG.PORT}`);
|
||||
|
||||
const guild = CONFIG.DISCORD_GUILD_ID
|
||||
@@ -203,21 +247,21 @@ client.once('ready', async () => {
|
||||
|
||||
registerCommands().catch(console.error);
|
||||
|
||||
gmailPollInterval = setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
|
||||
poll(client);
|
||||
|
||||
if (CONFIG.AUTO_CLOSE_ENABLED) {
|
||||
setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000);
|
||||
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));
|
||||
checkAutoClose(client, sendTicketClosedEmail);
|
||||
console.log('✓ Auto-close enabled: checking every hour');
|
||||
}
|
||||
|
||||
setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000);
|
||||
trackInterval(setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000));
|
||||
notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e));
|
||||
console.log('✓ Staff unclaimed reminders: checking every 30 minutes');
|
||||
|
||||
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
||||
setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000);
|
||||
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
|
||||
checkAutoUnclaim(client);
|
||||
console.log('✓ Auto-unclaim enabled: checking every hour');
|
||||
}
|
||||
@@ -225,21 +269,21 @@ client.once('ready', async () => {
|
||||
const { runPatternChecks } = require('./services/patternChecker');
|
||||
const { scheduleResets } = require('./services/patternStore');
|
||||
scheduleResets();
|
||||
setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000);
|
||||
trackInterval(setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000));
|
||||
console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`);
|
||||
|
||||
const { runSurgeChecks } = require('./services/surgeChecker');
|
||||
setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000);
|
||||
trackInterval(setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000));
|
||||
setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000);
|
||||
console.log('✓ Surge checks: every 5 minutes');
|
||||
|
||||
const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker');
|
||||
initChatMonitoring(client);
|
||||
setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000);
|
||||
trackInterval(setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000));
|
||||
console.log('✓ Chat alert monitoring: every 5 minutes');
|
||||
|
||||
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
|
||||
setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000);
|
||||
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
|
||||
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
|
||||
|
||||
if (!CONFIG.STAFF_IDS.length) {
|
||||
@@ -266,20 +310,26 @@ client.login(CONFIG.DISCORD_TOKEN);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.get('/', (req, res) => res.send('Active'));
|
||||
// Mount bOSScord API only after MongoDB is connected (inside ready), to avoid 500 on first request
|
||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||
app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
// Reject API traffic with 503 until ready event has fired and routes are mounted.
|
||||
let appReady = false;
|
||||
app.use((req, res, next) => {
|
||||
if (!appReady && req.path.startsWith('/api')) {
|
||||
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
|
||||
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount.
|
||||
|
||||
// --- Internal API for settings site ---
|
||||
const internalApi = require('./routes/internalApi');
|
||||
const internalApp = express();
|
||||
internalApp.use('/internal', internalApi);
|
||||
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
if (CONFIG.INTERNAL_API_SECRET) {
|
||||
internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => {
|
||||
internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => {
|
||||
console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`);
|
||||
});
|
||||
} else {
|
||||
@@ -287,8 +337,23 @@ if (CONFIG.INTERNAL_API_SECRET) {
|
||||
}
|
||||
|
||||
// --- Shutdown & error handlers ---
|
||||
let shuttingDown = false;
|
||||
async function handleShutdown(signal) {
|
||||
await Promise.race([logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), new Promise(r => setTimeout(r, 2000))]);
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await Promise.race([
|
||||
logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
|
||||
new Promise(r => setTimeout(r, 2000))
|
||||
]);
|
||||
for (const handle of activeIntervals) {
|
||||
try { clearInterval(handle); } catch (_) {}
|
||||
}
|
||||
activeIntervals.clear();
|
||||
gmailPollInterval = null;
|
||||
try { if (httpServer) await new Promise(r => httpServer.close(() => r())); } catch (_) {}
|
||||
try { if (internalServer) await new Promise(r => internalServer.close(() => r())); } catch (_) {}
|
||||
try { client.destroy(); } catch (_) {}
|
||||
try { await closeMongoDB(); } catch (_) {}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||
@@ -300,6 +365,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
module.exports = {
|
||||
client,
|
||||
setGmailPollInterval,
|
||||
clearGmailPollInterval,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
getNextTicketNumber,
|
||||
|
||||
Reference in New Issue
Block a user