security hardening

This commit is contained in:
2026-04-18 11:10:41 +00:00
parent a409203025
commit 21618efbad
36 changed files with 1455 additions and 283 deletions

View File

@@ -95,4 +95,20 @@ function enqueueMove(channel, categoryId) {
return channel.setParent(categoryId, { lockPermissions: true });
}
module.exports = { enqueueRename, enqueueMove };
// Per-channel promise chain for send ordering and to prevent interleaving.
const sendChains = new Map();
function enqueueSend(channel, ...args) {
if (!channel || typeof channel.send !== 'function') {
return Promise.reject(new Error('enqueueSend: invalid channel'));
}
const prev = sendChains.get(channel.id) || Promise.resolve();
const next = prev.catch(() => {}).then(() => channel.send(...args));
sendChains.set(channel.id, next);
next.catch(() => {}).finally(() => {
if (sendChains.get(channel.id) === next) sendChains.delete(channel.id);
});
return next;
}
module.exports = { enqueueRename, enqueueMove, enqueueSend };

View File

@@ -5,6 +5,7 @@
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
const chatState = new Map();
@@ -64,7 +65,7 @@ async function runChatAlertChecks(client) {
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await alertChan.send({ content, embeds: [embed] });
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}
@@ -82,7 +83,7 @@ async function runChatAlertChecks(client) {
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await alertChan.send({ content, embeds: [embed] });
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}

View File

@@ -15,65 +15,6 @@ function getGmailClient() {
return google.gmail({ version: 'v1', auth });
}
async function sendGmailReply(
threadId,
replyText,
recipientEmail,
subject,
discordUser,
messageId
) {
const gmail = getGmailClient();
const utf8Subject = `=?utf-8?B?${Buffer.from(
`Re: ${subject}`
).toString('base64')}?=`;
const safeUser = escapeHtml(discordUser);
const safeReply = escapeHtml(replyText).replace(/\n/g, '<br>');
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${safeUser} on Discord</p>
<p>${safeReply}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
</td>
</tr>
</table>
</div>`;
const headers = [
`From: ${CONFIG.MY_EMAIL}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
messageId ? `In-Reply-To: ${messageId}` : '',
messageId ? `References: ${messageId}` : '',
'MIME-Version: 1.0',
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody
].filter(Boolean);
const raw = Buffer.from(headers.join('\r\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw, threadId }
});
}
async function sendTicketClosedEmail(ticket, discordDisplayName) {
try {
const gmail = getGmailClient();
@@ -105,13 +46,15 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
finalSubject
).toString('base64')}?=`;
const serverDisplayName = escapeHtml(discordDisplayName || 'Support');
const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support');
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
<p><strong>Message:</</strong></p>
<p><strong>Message:</strong></p>
<p>${safeCloseMessage}</p>
<p style="margin-top: 16px;">${safeCloseSignature}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
@@ -202,6 +145,9 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
}
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const serverDisplayName = label;
const safeCloseMessage = safeBody;
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>

View File

@@ -6,6 +6,7 @@ const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
@@ -28,7 +29,7 @@ async function postPattern(client, channelConfigKey, embed) {
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (channel) await channel.send({ embeds: [embed] });
if (channel) await enqueueSend(channel, { embeds: [embed] });
} catch (_) {}
}

View File

@@ -128,9 +128,10 @@ function shouldFireCooldownEscalating(key, thresholdsMs) {
let state = escalatingCooldowns.get(key);
if (!state) {
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0 };
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
escalatingCooldowns.set(key, state);
}
state.lastUsed = now;
const nextThreshold = sortedThresholds[state.fireCount];
if (typeof nextThreshold !== 'number') return null;
@@ -147,6 +148,19 @@ function clearEscalating(key) {
escalatingCooldowns.delete(key);
}
const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000;
const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function cleanupStaleEscalatingCooldowns() {
const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS;
for (const [key, state] of escalatingCooldowns.entries()) {
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
}
}
setInterval(cleanupStaleEscalatingCooldowns, ESCALATING_CLEANUP_INTERVAL_MS).unref?.();
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();

View File

@@ -1,4 +1,5 @@
const { CONFIG } = require('../config');
const { enqueueSend } = require('./channelQueue');
/**
* Create a staff tracking channel for a ticket.
@@ -33,7 +34,7 @@ async function createStaffChannel(guild, ticket, claimerId, channelName) {
.setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` })
.setTimestamp();
const pinMsg = await staffChan.send({ embeds: [embed] });
const pinMsg = await enqueueSend(staffChan, { embeds: [embed] });
await pinMsg.pin().catch(() => {});
return staffChan;
@@ -50,7 +51,7 @@ async function pingStaffChannel(staffChannel, claimerId, originalMessage) {
if (!staffChannel) return;
try {
const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`;
await staffChannel.send(
await enqueueSend(staffChannel,
`<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})`
);
} catch (e) {

View File

@@ -11,6 +11,7 @@
const { mongoose } = require('../db-connection');
const { CONFIG, parseThresholdString } = require('../config');
const { increment } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
const StaffNotification = mongoose.model('StaffNotification');
@@ -39,7 +40,8 @@ async function notifyStaffOfReply(guild, ticket, message) {
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
const snippet = message.content?.slice(0, 300) || '(no text)';
await notifChannel.send(
await enqueueSend(
notifChannel,
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
);
@@ -82,7 +84,7 @@ async function notifyAllStaffUnclaimed(client) {
for (const ticket of unclaimedTickets) {
const ageMs = now - new Date(ticket.createdAt).getTime();
const ageHours = ageMs / (60 * 60 * 1000);
const alreadySent = ticket.unclaimedReminderssent || [];
const alreadySent = ticket.unclaimedRemindersSent || [];
// Find thresholds crossed but not yet sent
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
@@ -100,7 +102,7 @@ async function notifyAllStaffUnclaimed(client) {
for (const rec of staffRecords) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
if (chan) {
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e));
increment('staff_stale_pings', rec.userId, 'today');
increment('staff_stale_pings', rec.userId, 'week');
}
@@ -108,7 +110,7 @@ async function notifyAllStaffUnclaimed(client) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $addToSet: { unclaimedReminderssent: highest } }
{ $addToSet: { unclaimedRemindersSent: highest } }
);
}
}

View File

@@ -5,7 +5,7 @@
* Notes:
* - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS
* permissions on every ticket category.
* - Private threads (type: 12) require the server to have Community features
* - Private threads (ChannelType.PrivateThread) require the server to have Community features
* OR the channel to be in a server with Boost level that unlocks private
* threads. If thread creation fails with code 50024 or 160004, a warning
* is logged via logWarn.
@@ -15,6 +15,7 @@
* servers. The 300ms delay between adds avoids the thread member add rate
* limit (approximately 5/second).
*/
const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config');
const { logError, logWarn } = require('./debugLog');
@@ -32,7 +33,7 @@ async function createStaffThread(channel, client) {
const thread = await channel.threads.create({
name: threadName,
type: 12, // ChannelType.PrivateThread
type: ChannelType.PrivateThread,
invitable: false,
reason: 'Staff discussion thread for ticket'
});
@@ -84,7 +85,7 @@ async function addMemberToStaffThread(channel, memberId) {
try {
const threads = await channel.threads.fetchActive();
const staffThread = threads.threads.find(t =>
t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12
t.name === CONFIG.STAFF_THREAD_NAME && t.type === ChannelType.PrivateThread
);
if (!staffThread) return;
await staffThread.members.add(memberId);

View File

@@ -7,6 +7,7 @@ const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore');
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
const { enqueueSend } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
@@ -37,7 +38,7 @@ async function pingStaff(client, message, embedFields) {
})));
}
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
await channel.send({ content, embeds: [embed] });
await enqueueSend(channel, { content, embeds: [embed] });
} catch (_) {}
}

View File

@@ -7,6 +7,7 @@ const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
@@ -89,48 +90,51 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
async function canRename(ticket) {
const now = Date.now();
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
.select('renameCount renameWindowStart')
.lean();
if (!fresh) {
return { ok: false, remaining: 0, waitMs: RENAME_WINDOW_MS };
const windowCutoff = new Date(now - RENAME_WINDOW_MS);
// Atomic: reset the window if the stored start is older than the cutoff; count = 1.
const resetDoc = await Ticket.findOneAndUpdate(
{
gmailThreadId: ticket.gmailThreadId,
$or: [
{ renameWindowStart: { $lt: windowCutoff } },
{ renameWindowStart: null },
{ renameWindowStart: { $exists: false } }
]
},
{ $set: { renameWindowStart: new Date(now), renameCount: 1 } },
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
).lean();
if (resetDoc) {
ticket.renameWindowStart = resetDoc.renameWindowStart;
ticket.renameCount = resetDoc.renameCount;
return { ok: true, remaining: RENAME_LIMIT - resetDoc.renameCount, waitMs: 0 };
}
const windowStart = (fresh.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || 0;
const count = fresh.renameCount || 0;
if (now - windowStart >= RENAME_WINDOW_MS) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { renameWindowStart: new Date(now), renameCount: 0 } }
);
ticket.renameWindowStart = new Date(now);
ticket.renameCount = 0;
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
}
if (count >= RENAME_LIMIT) {
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
return { ok: false, remaining: 0, waitMs };
}
const updated = await Ticket.findOneAndUpdate(
{ gmailThreadId: ticket.gmailThreadId },
// Atomic: within window, only increment if count < limit.
const incDoc = await Ticket.findOneAndUpdate(
{
gmailThreadId: ticket.gmailThreadId,
renameCount: { $lt: RENAME_LIMIT }
},
{ $inc: { renameCount: 1 } },
{ returnDocument: 'after' }
)
.select('renameCount renameWindowStart')
.lean();
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
).lean();
if (!updated) {
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
return { ok: false, remaining: 0, waitMs };
if (incDoc) {
ticket.renameWindowStart = incDoc.renameWindowStart;
ticket.renameCount = incDoc.renameCount;
return { ok: true, remaining: RENAME_LIMIT - incDoc.renameCount, waitMs: 0 };
}
const newCount = updated.renameCount || 0;
ticket.renameCount = newCount;
ticket.renameWindowStart = updated.renameWindowStart;
return { ok: true, remaining: RENAME_LIMIT - newCount, waitMs: 0 };
// At limit — read the window start to compute waitMs.
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
.select('renameWindowStart')
.lean();
const windowStart = (fresh?.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || now;
const waitMs = Math.max(0, RENAME_WINDOW_MS - (now - windowStart));
return { ok: false, remaining: 0, waitMs };
}
function minutesFromMs(ms) {
@@ -487,7 +491,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
@@ -531,7 +535,7 @@ async function checkReminders(client) {
const message = CONFIG.REMINDER_MESSAGE
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
.replace(/\{ping\}/g, ping);
await channel.send(message);
await enqueueSend(channel, message);
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
@@ -570,7 +574,7 @@ async function checkAutoUnclaim(client) {
{ $set: { claimedBy: null } }
));
await channel.send(
await enqueueSend(channel,
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
);