Files
broccolini-bot/utils.js
indifferentketchup 3c13e55dad audit week 3 quality batch: QUAL-004/005/007/008/010 + SEC-002
QUAL-004 handlers/messages.js — DM-on-customer-reply now reads
guild.members.cache.get(claimerId) first and only falls back to
guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff
reply on busy tickets. GuildMembers intent already keeps the cache warm.

QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js
(finalizeForceClose) — close paths now $unset welcomeMessageId alongside
the status: 'closed' write. Stops a stale message-ID from carrying into a
future reopen on the same Gmail thread, where escalation's "edit welcome
buttons" path would silently fail trying to fetch a message in a deleted
channel.

QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now
includes the missing/extra key sets, not just count vs count. Saves the
operator from guessing which key vanished after a partial write.

QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match
loop with an earliest-match-across-all-markers scan. The previous code
could truncate at a late "_____" signature underline even when an earlier
"On X wrote:" reply header was the real cutoff. New test in
tests/utils.test.js exercises the dual-marker case.

QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer /
appReady` declarations from after the ready handler to before it. Same
runtime behavior (module-load completes before ready fires asynchronously),
but the read order now matches the assignment order.

SEC-002 routes/internalApi.js — POST /restart now goes through a tighter
2/min limiter on top of the shared 10/min internalLimiter. Defense in
depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret
can no longer crash-loop the container.

Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already
idempotent — the audit finding was incorrect).

vitest run: 88/88 (one new test for QUAL-008).
2026-05-08 20:46:04 +00:00

289 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure utility functions text processing, date formatting, game detection,
* priority helpers, template variables.
*/
const crypto = require('crypto');
const { CONFIG, GAME_NAMES, GAME_ALIASES } = require('./config');
/** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */
function safeEqual(a, b) {
const ab = Buffer.from(String(a || ''), 'utf8');
const bb = Buffer.from(String(b || ''), 'utf8');
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
/** True if the member holds ROLE_ID_TO_PING or any ADDITIONAL_STAFF_ROLES. Safe for null/undefined members. */
function isStaff(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
// --- TEXT PROCESSING ---
const BLOCK_TAG_REGEX =
/<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi;
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Escape for safe use in HTML body (prevents XSS in outgoing emails). */
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function decodeHtmlEntities(str) {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
}
function htmlToTextWithBlocks(html) {
return decodeHtmlEntities(
html
.replace(/\r\n/g, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(BLOCK_TAG_REGEX, '\n\n')
.replace(/<(ul|ol)[^>]*>/gi, '\n')
.replace(/<[^>]*>?/gm, '')
);
}
// --- EMAIL BODY EXTRACTION ---
function decodeGmailData(p) {
if (!p.body?.data) return '';
let data = Buffer.from(p.body.data, 'base64').toString('utf8');
const isQuotedPrintable = p.headers?.some(
h =>
h.name.toLowerCase() === 'content-transfer-encoding' &&
h.value.toLowerCase() === 'quoted-printable'
);
if (isQuotedPrintable) {
data = data
.replace(/=\r?\n/g, '')
.replace(/=([0-9A-F]{2})/gi, (m, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
}
return data;
}
function getCleanBody(payload) {
let body = '';
const findParts = parts => {
for (const part of parts) {
if (part.mimeType === 'text/plain' && part.body?.data && !body) {
body = decodeGmailData(part);
}
if (part.mimeType === 'text/html' && part.body?.data && !body) {
body = decodeGmailData(part);
}
if (part.parts) findParts(part.parts);
}
};
if (payload.parts) {
findParts(payload.parts);
} else if (payload.body?.data) {
body = decodeGmailData(payload);
}
return body || payload.snippet || '';
}
// --- QUOTE / FOOTER STRIPPING ---
function stripEmailQuotes(text) {
let cleaned = text.replace(/\r\n/g, '\n');
// Pick the earliest match across all markers, not just the first marker that
// matches anywhere. The previous order-dependent loop could truncate at a
// late "_____" signature underline even when an earlier "On X wrote:" reply
// header was the real cutoff.
const markers = [
/\nOn .* wrote:/i,
/\nFrom:\s.*<.*@.*>/i,
/\nSent:\s.*$/i,
/\nTo:\s.*$/i,
/\nSubject:\s.*$/i,
/\n_{5,}\s*$/m
];
let earliest = -1;
for (const m of markers) {
const match = cleaned.match(m);
if (match && (earliest === -1 || match.index < earliest)) {
earliest = match.index;
}
}
if (earliest !== -1) {
cleaned = cleaned.substring(0, earliest);
}
return cleaned.trim();
}
// Hoisted to module scope: constructed once at load, not per-call.
const MOBILE_FOOTER_REGEXES = [
/Sent from my iPhone/i,
/Sent from my iPad/i,
/Sent from my Apple Watch/i,
/Sent from my Mac/i,
/Sent from my mobile device/i,
/Sent from my phone/i,
/Sent from my smartphone/i,
/Sent from my Android(?: phone| device)?/i,
/Sent from my Samsung Galaxy smartphone/i,
/Sent from Samsung Mobile/i,
/Sent from my Galaxy/i,
/Sent from my BlackBerry/i,
/Sent from my Windows Phone/i,
/Sent from Outlook for iOS/i,
/Sent from Outlook for Android/i,
/Sent from Yahoo Mail for iPhone(?: \/ Android)?/i,
/Sent from Yahoo Mail for Android/i,
/Sent from my Amazon Fire/i,
/Get\s+Outlook\s+for\s+iOS/i,
/Get\s+Outlook\s+for\s+Android/i,
/Sent with Proton Mail secure email\./i
].map(re => new RegExp(`\\n*${re.source}\\s*`, 'i'));
function stripMobileFooter(text) {
if (!text) return text;
let result = text;
for (const rx of MOBILE_FOOTER_REGEXES) {
result = result.replace(rx, '');
}
return result;
}
// --- EMAIL HELPERS ---
function extractRawEmail(headerValue) {
const match = headerValue.match(/<([^>]+)>/);
return match ? match[1].trim() : headerValue.trim();
}
// --- GAME DETECTION ---
// Map<lowercase-alias, { canonical, re }> built once at module load so detectGame
// doesn't allocate a fresh RegExp per game/alias per call.
const GAME_DETECTION = (() => {
const m = new Map();
const add = (key, canonical) => {
const lower = String(key).toLowerCase();
if (m.has(lower)) return;
m.set(lower, { canonical, re: new RegExp(`\\b${escapeRegex(lower)}\\b`, 'i') });
};
for (const game of GAME_NAMES) add(game, game);
for (const [alias, fullName] of Object.entries(GAME_ALIASES)) add(alias, fullName);
return m;
})();
const detectGame = (subject, body) => {
const txt = `${subject} ${body}`.toLowerCase();
for (const { re, canonical } of GAME_DETECTION.values()) {
if (re.test(txt)) return canonical;
}
return 'Not Mentioned';
};
// --- PRIORITY ---
function getPriorityEmoji(priority) {
switch (priority) {
case 'high': return CONFIG.PRIORITY_HIGH_EMOJI;
case 'low': return CONFIG.PRIORITY_LOW_EMOJI;
case 'normal':
case 'medium':
default: return CONFIG.PRIORITY_MEDIUM_EMOJI;
}
}
// --- TEMPLATE VARIABLES ---
function replaceVariables(template, context = {}) {
if (!template) return '';
let result = template;
if (context.ticket) {
result = result.replace(/{ticket\.user}/g, context.ticket.sender_name || 'Unknown');
result = result.replace(/{ticket\.creator}/g, context.ticket.sender_name || 'Unknown');
result = result.replace(/{ticket\.email}/g, context.ticket.senderEmail || '');
result = result.replace(/{ticket\.number}/g, context.ticket.ticketNumber != null ? context.ticket.ticketNumber : 'N/A');
result = result.replace(/{ticket\.subject}/g, context.ticket.subject || 'No subject');
result = result.replace(/{ticket\.claimed}/g, context.ticket.claimedBy ? 'Yes' : 'No');
result = result.replace(/{ticket\.claimedby}/g, context.ticket.claimedBy || 'Unclaimed');
result = result.replace(/{ticket\.priority}/g, context.ticket.priority || 'normal');
result = result.replace(/{ticket\.id}/g, context.ticket.gmailThreadId || '');
}
if (context.staff) {
result = result.replace(/{staff\.user}/g, context.staff.username || '');
result = result.replace(/{staff\.name}/g, context.staff.displayName || context.staff.username || '');
result = result.replace(/{staff\.mention}/g, context.staff.mention || '');
}
if (context.guild) {
result = result.replace(/{server\.name}/g, context.guild.name || '');
result = result.replace(/{server\.membercount}/g, context.guild.memberCount?.toString() || '0');
}
if (context.hours !== undefined) {
result = result.replace(/{hours}/g, context.hours.toString());
}
const now = new Date();
result = result.replace(/{date}/g, now.toLocaleDateString());
result = result.replace(/{time}/g, now.toLocaleTimeString());
return result;
}
/** Sanitize user input for safe embedding in Discord code blocks. */
function sanitizeEmbedText(str) {
if (str == null) return '';
return String(str).replace(/```/g, "'''").trim();
}
// --- EMBED TRUNCATION ---
/** Truncate a string for use as an embed description (max 4096). */
function truncateEmbedDescription(str, max = 4096) {
if (str == null) return '';
const s = String(str);
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
module.exports = {
sanitizeEmbedText,
truncateEmbedDescription,
escapeHtml,
safeEqual,
isStaff,
htmlToTextWithBlocks,
getCleanBody,
stripEmailQuotes,
stripMobileFooter,
extractRawEmail,
detectGame,
getPriorityEmoji,
replaceVariables
};