Skip to content
Open
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions src/commands/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
const { SlashCommandBuilder } = require("@discordjs/builders");
const { MessageFlags, AttachmentBuilder, PermissionFlagsBits } = require('discord.js');
const database = require("../database/Database.js");

module.exports = {
data: new SlashCommandBuilder()
.setName("export")
.setDescription("Export verification logs as CSV")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(subcommand =>
subcommand
.setName('logs')
.setDescription('Export verification log channel messages as CSV')
.addIntegerOption(option =>
option
.setName('limit')
.setDescription('Maximum number of messages to fetch (default: 1000, max: 10000)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(10000)
)
),

async execute(interaction) {
const subcommand = interaction.options.getSubcommand();

if (subcommand === 'logs') {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });

await database.getServerSettings(interaction.guildId, async serverSettings => {
if (!serverSettings.logChannel || serverSettings.logChannel === "") {
await interaction.editReply({
content: "❌ **No log channel configured!**\n\nUse `/settings logchannel` to set up a verification log channel first.",
});
return;
}

const logChannel = interaction.guild.channels.cache.get(serverSettings.logChannel);
if (!logChannel) {
await interaction.editReply({
content: "❌ **Log channel not found!**\n\nThe configured log channel may have been deleted.",
});
return;
}

const limit = interaction.options.getInteger('limit') || 1000;

try {
// Fetch messages in batches (Discord API limit is 100 per request)
let allMessages = [];
let lastMessageId = null;
let remaining = limit;

await interaction.editReply({
content: `⏳ Fetching messages from <#${serverSettings.logChannel}>...`,
});

while (remaining > 0) {
const fetchLimit = Math.min(remaining, 100);
const options = { limit: fetchLimit };
if (lastMessageId) {
options.before = lastMessageId;
}

const messages = await logChannel.messages.fetch(options);
if (messages.size === 0) break;

// Filter to only bot's own messages
const botMessages = messages.filter(msg => msg.author.id === interaction.client.user.id);
allMessages.push(...botMessages.values());

lastMessageId = messages.last().id;
remaining -= messages.size;

// Break if we got fewer messages than requested (end of channel)
if (messages.size < fetchLimit) break;
}

if (allMessages.length === 0) {
await interaction.editReply({
content: "❌ **No verification logs found!**\n\nThe bot hasn't logged any verifications yet, or the messages have been deleted.",
});
return;
}

// Parse messages and build CSV
const csvRows = ['timestamp,user_id,username,email,type,verified_by,tags'];
let successCount = 0;
let parseErrors = 0;

for (const message of allMessages) {
const parsed = parseLogMessage(message.content);
if (parsed) {
// Try to get username from mention
let username = '';
try {
const member = await interaction.guild.members.fetch(parsed.userId).catch(() => null);
username = member ? member.user.username : '';
} catch {
username = '';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-message member fetch causes timeout on large exports

Medium Severity

For every parsed log message, interaction.guild.members.fetch(parsed.userId) makes a Discord API call. With up to 10,000 messages and many unique users (especially ones who've left the server and aren't cached), this can trigger thousands of sequential API calls subject to rate limiting. This risks exceeding the 15-minute deferred interaction timeout, causing the export to silently fail.

Fix in Cursor Fix in Web


// Replace commas in tags with semicolons to avoid CSV issues
const tags = parsed.tags ? parsed.tags.replace(/,/g, ';') : '';

csvRows.push([
message.createdAt.toISOString(),
parsed.userId,
escapeCsvField(username),
escapeCsvField(parsed.email),
parsed.type,
parsed.verifiedBy || '',
tags
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tags field missing CSV escape handling for quotes/newlines

Medium Severity

The tags field is not passed through escapeCsvField() before being included in the CSV output. While commas are replaced with semicolons, quotes and newlines in tag values would corrupt the CSV structure. The regex pattern [^\]]+ allows tags to contain quotes or newlines, which need proper escaping (wrapping in quotes and doubling internal quotes) to produce valid CSV. Unlike username and email which use escapeCsvField(), the tags field bypasses this protection.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tags field not escaped for CSV output

Medium Severity

The tags field is added directly to the CSV row without being passed through escapeCsvField(), unlike username and email which are properly escaped. The tags originate from Discord role names (via assignedRoleNames), which can contain double quotes or newlines. Replacing commas with semicolons on line 104 doesn't protect against these other CSV-breaking characters, resulting in malformed CSV output.

Fix in Cursor Fix in Web

].join(','));
successCount++;
} else {
parseErrors++;
}
}

if (successCount === 0) {
await interaction.editReply({
content: "❌ **Could not parse any log messages!**\n\nThe log format may have changed or messages are in an unexpected format.",
});
return;
}

// Create CSV file
const csvContent = csvRows.join('\n');
const buffer = Buffer.from(csvContent, 'utf-8');
const attachment = new AttachmentBuilder(buffer, {
name: `verification-logs-${interaction.guildId}-${Date.now()}.csv`
});

let summaryMessage = `✅ **Export complete!**\n\n`;
summaryMessage += `📊 **Entries exported:** ${successCount}\n`;
if (parseErrors > 0) {
summaryMessage += `⚠️ **Parse errors:** ${parseErrors} (unrecognized format)\n`;
}
summaryMessage += `📅 **Date range:** ${allMessages[allMessages.length - 1].createdAt.toLocaleDateString()} - ${allMessages[0].createdAt.toLocaleDateString()}`;

await interaction.editReply({
content: summaryMessage,
files: [attachment]
});

} catch (error) {
console.error('Export error:', error);
await interaction.editReply({
content: "❌ **Export failed!**\n\nMake sure the bot has permission to read the log channel.",
});
}
});
}
},
};

/**
* Parse a log message and extract user ID and email
* Formats:
* - Current: ✅ <@123456789> → `email@example.com`
* - With tags: ✅ <@123456789> → `email@example.com` [Ver, TEST]
* - Manual: 🔧 <@123456789> → `email@example.com` (by <@987654321>)
* - Legacy: Authorized: <@123456789> → email@example.com
*/
function parseLogMessage(content) {
// Current format with optional tags: ✅ <@userId> → `email` [tags]
const regularMatchWithTags = content.match(/^✅\s*<@!?(\d+)>\s*→\s*`([^`]+)`\s*\[([^\]]+)\]$/);
if (regularMatchWithTags) {
return {
userId: regularMatchWithTags[1],
email: regularMatchWithTags[2],
type: 'auto',
verifiedBy: null,
tags: regularMatchWithTags[3]
};
}

// Current format without tags: ✅ <@userId> → `email`
const regularMatch = content.match(/^✅\s*<@!?(\d+)>\s*→\s*`([^`]+)`$/);
if (regularMatch) {
return {
userId: regularMatch[1],
email: regularMatch[2],
type: 'auto',
verifiedBy: null,
tags: null
};
}

// Manual verification with optional tags: 🔧 <@userId> → `email` (by <@adminId>) [tags]
const manualMatchWithTags = content.match(/^🔧\s*<@!?(\d+)>\s*→\s*`([^`]+)`\s*\(by\s*<@!?(\d+)>\)\s*\[([^\]]+)\]$/);
if (manualMatchWithTags) {
return {
userId: manualMatchWithTags[1],
email: manualMatchWithTags[2],
type: 'manual',
verifiedBy: manualMatchWithTags[3],
tags: manualMatchWithTags[4]
};
}

// Manual verification without tags: 🔧 <@userId> → `email` (by <@adminId>)
const manualMatch = content.match(/^🔧\s*<@!?(\d+)>\s*→\s*`([^`]+)`\s*\(by\s*<@!?(\d+)>\)$/);
if (manualMatch) {
return {
userId: manualMatch[1],
email: manualMatch[2],
type: 'manual',
verifiedBy: manualMatch[3],
tags: null
};
}

// Legacy format: Authorized: <@userId> → email
const legacyMatch = content.match(/^Authorized:\s*<@!?(\d+)>\s*→\s*(\S+@\S+)$/);
if (legacyMatch) {
return {
userId: legacyMatch[1],
email: legacyMatch[2].trim(),
type: 'auto',
verifiedBy: null,
tags: null
};
}

return null;
}

/**
* Escape a field for CSV (handle commas, quotes, newlines)
*/
function escapeCsvField(field) {
if (!field) return '';
const str = String(field);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}