audit
This commit is contained in:
@@ -7,6 +7,7 @@ const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logSecurity } = require('../services/debugLog');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
const { isStaff } = require('../utils');
|
||||
|
||||
const User = mongoose.model('User');
|
||||
|
||||
@@ -134,6 +135,13 @@ async function handleAccountInfoCommand(interaction) {
|
||||
async function handleSendAccountInfoToChannel(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
|
||||
|
||||
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
|
||||
if (!isStaff(interaction.member)) {
|
||||
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
|
||||
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
|
||||
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { trackInteraction, trackError } = require('./analytics');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { increment } = require('../services/patternStore');
|
||||
const { logError } = require('../services/debugLog');
|
||||
const { logError, logSystem } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -497,8 +497,10 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
});
|
||||
}
|
||||
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets)
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets).
|
||||
// Gated because many users have DMs from server members disabled — the send
|
||||
// then 50007s and generates noise. Default off; enable via env when desired.
|
||||
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
@@ -515,7 +517,15 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
files: [dmFile]
|
||||
});
|
||||
} catch (dmErr) {
|
||||
console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message);
|
||||
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only.
|
||||
if (dmErr?.code === 50007) {
|
||||
logSystem('Transcript DM skipped (recipient has DMs disabled)', [
|
||||
{ name: 'User', value: creatorId },
|
||||
{ name: 'Channel', value: channelName }
|
||||
]).catch(() => {});
|
||||
} else {
|
||||
logError('transcript-dm', dmErr).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +653,7 @@ async function handleTicketModal(interaction) {
|
||||
}
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
||||
channel = await guild.channels.create({
|
||||
name: unclaimedName,
|
||||
type: ChannelType.GuildText,
|
||||
|
||||
@@ -440,6 +440,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
await interaction.channel.permissionOverwrites.create(user.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
@@ -462,6 +463,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
await interaction.channel.permissionOverwrites.delete(user.id);
|
||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
@@ -524,6 +526,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
|
||||
await interaction.channel.setParent(category.id, { lockPermissions: true });
|
||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
@@ -711,6 +714,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
await interaction.channel.setTopic(text);
|
||||
await interaction.reply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
@@ -1085,19 +1089,46 @@ async function handleCommand(interaction) {
|
||||
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
|
||||
}
|
||||
try {
|
||||
const tickets = await Ticket.find().sort({ ticketNumber: 1 }).lean();
|
||||
const lines = ['# Ticket backup – ' + new Date().toISOString(), 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier'];
|
||||
for (const t of tickets) {
|
||||
// Stream every ticket through a Mongoose cursor to a tmp file so peak RSS
|
||||
// stays bounded regardless of collection size; attach the file, then unlink.
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`;
|
||||
const tmpPath = path.join(os.tmpdir(), tmpName);
|
||||
const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
|
||||
|
||||
ws.write('# Ticket backup – ' + new Date().toISOString() + '\n');
|
||||
ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n');
|
||||
|
||||
let count = 0;
|
||||
const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor();
|
||||
for await (const t of cursor) {
|
||||
const created = t.createdAt ? new Date(t.createdAt).toISOString() : '';
|
||||
lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t'));
|
||||
ws.write([
|
||||
t.ticketNumber,
|
||||
t.status || '',
|
||||
(t.senderEmail || '').replace(/\t/g, ' '),
|
||||
(t.subject || '').replace(/\t/g, ' ').slice(0, 200),
|
||||
created,
|
||||
(t.claimedBy || '').replace(/\t/g, ' '),
|
||||
t.priority || '',
|
||||
t.escalationTier ?? ''
|
||||
].join('\t') + '\n');
|
||||
count++;
|
||||
}
|
||||
await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve()));
|
||||
|
||||
try {
|
||||
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
|
||||
await enqueueSend(channel, {
|
||||
content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`,
|
||||
files: [new AttachmentBuilder(tmpPath, { name: tmpName })]
|
||||
});
|
||||
await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`);
|
||||
} finally {
|
||||
fs.promises.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
const buf = Buffer.from(lines.join('\n'), 'utf8');
|
||||
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
|
||||
await enqueueSend(channel, {
|
||||
content: `Ticket backup by ${interaction.user.tag} (${tickets.length} tickets)`,
|
||||
files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })]
|
||||
});
|
||||
await interaction.editReply(`Backup complete. ${tickets.length} tickets sent to the backup channel.`);
|
||||
} catch (err) {
|
||||
trackError('backup-command', err, interaction);
|
||||
await interaction.editReply('Failed to create backup: ' + (err.message || err));
|
||||
|
||||
Reference in New Issue
Block a user