From 33b1f276c652cea3e27007a43ed101c33f53f023 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 20 Apr 2026 18:05:36 +0000 Subject: [PATCH] audit --- .env.example | 1 + CLAUDE.md | 10 +- broccolini-discord.js | 24 +++- config.js | 90 ++++++++------- docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md | 4 +- docs/features/IMPLEMENTATION_SUMMARY.md | 2 +- docs/features/PHASE_FEATURES.md | 2 +- docs/setup/MONGODB_SETUP.md | 4 +- gmail-poll.js | 16 ++- handlers/accountinfo.js | 8 ++ handlers/buttons.js | 19 +++- handlers/commands.js | 53 +++++++-- models.js | 3 +- routes/bosscord.js | 11 +- routes/internalApi.js | 11 +- services/channelQueue.js | 30 ++++- services/configPersistence.js | 143 +++++++++++++++++++----- services/gmail.js | 47 +++++--- services/patternStore.js | 51 ++++++++- services/staffChannel.js | 1 + services/staffNotifications.js | 32 +++++- services/staffSignature.js | 3 +- services/tickets.js | 80 +++++++++++-- settings-site/docker-compose.yml | 5 + settings-site/server.js | 31 ++++- utils.js | 100 ++++++++++------- 26 files changed, 598 insertions(+), 183 deletions(-) diff --git a/.env.example b/.env.example index 99d1ce4..3da7409 100644 --- a/.env.example +++ b/.env.example @@ -180,6 +180,7 @@ STAFF_THREAD_ROLE_ID= # Role whose members are added to the PIN_INITIAL_MESSAGE_ENABLED=false # Auto-pin the welcome message on ticket creation PIN_ESCALATION_MESSAGE_ENABLED=false # Auto-pin escalation messages PIN_SUPPRESS_SYSTEM_MESSAGE=false # Delete the "X pinned a message" system message after pinning +TRANSCRIPT_DM_TO_CREATOR=false # DM the transcript file to the ticket creator on close (Discord-origin tickets only) # --- Settings site & internal API --- SETTINGS_PORT=12752 # Port for the settings web UI diff --git a/CLAUDE.md b/CLAUDE.md index 1c18186..97a8f89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ When the user asks for direct fixes, make them — but still avoid unsolicited r - `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`). - `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists. - No lint step configured. No unit/integration test framework. +- **Verification:** prefer `node --check ` for syntax, and inline `node -e "..."` for behavior. For tightly-coupled modules, stub deps via `Module._resolveFilename` override (see `services/channelQueue.js` tests in session history). Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime. @@ -41,7 +42,7 @@ Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas) 1. **CommonJS only.** `require` / `module.exports`. Never `import`. 2. **Read before write.** Never propose or make changes to a file without first reading its current contents. -3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend(channel, ...args)`, `enqueueRename(channel, name)`, `enqueueMove(channel, categoryId)`. Direct `channel.send(...)` / `channel.setName(...)` calls bypass ordering + rate-limit protection. **Audit note:** several files still bypass (`handlers/commands.js`, `handlers/buttons.js`, `handlers/accountinfo.js`, `handlers/setup.js`, `services/tickets.js`, `services/debugLog.js`, `services/patternChecker.js`, `services/surgeChecker.js`, `services/chatAlertChecker.js`, `services/staffChannel.js`, `routes/bosscord.js:191`) — treat as in-flight cleanup, migrate sends incrementally when touching those files. +3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend`, `enqueueRename`, `enqueueMove`, `enqueueDelete` (awaits both rename+send tails before deleting). Bypass sites are tagged `// TODO(queue-migrate):` — grep to find them; migrate incrementally when touching. 4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead. 5. **Use `ChannelType` enum from `discord.js`**, not bare integers (`0`, `4`, `5`, `12`, `15`). 6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time). @@ -62,6 +63,8 @@ Single Node process. Entry: `broccolini-discord.js`. - **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`. - **Internal (`internalApp`)** — `127.0.0.1` only, `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it. +`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure. + ### Intervals & shutdown - Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set. - `handleShutdown(signal)` is idempotent (`shuttingDown` flag): clears every tracked interval, closes both HTTP servers, calls `client.destroy()`, calls `closeMongoDB()`, then `process.exit(0)`. Wired to SIGTERM/SIGINT. @@ -86,6 +89,9 @@ Every `interactionCreate` branch runs through `runHandler(name, interaction, fn) - In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00. - `escalatingCooldowns` entries carry a `lastUsed` timestamp; a 6-hour interval prunes entries idle for >48h. The cleanup interval is `.unref()`-ed so shutdown isn't blocked by it. +### `.env` persistence (`services/configPersistence.js`) +- Values are stored in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside `"…"` (not `\"` or `\\`) — backticks preserve quotes + literal newlines verbatim. `readEnvFile` joins multi-line backtick values; `writeEnvFile` re-reads after write and throws on key-count mismatch. + ## bOSScord integration bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints. @@ -119,3 +125,5 @@ Names and full tables are in `README.md` / `.env.example`. Ones that commonly tr ## Settings site `settings-site/` contains a separate Express app (`settings-site/server.js`) for the admin UI — it talks to `internalApp` via `INTERNAL_API_SECRET`. It is **not** part of this bot's process. Changes to the bot's `/internal/config` contract (e.g., the `ALLOWED_CONFIG_KEYS` set) may break the settings UI. See `settings-site/CLAUDE.md` for that subproject's architecture and conventions. + +Its Docker build context is `settings-site/` only — parent-repo files (e.g., `utils.js`) are unreachable inside the container. Any shared helper must be inlined or the build context widened in `docker-compose.yml`. diff --git a/broccolini-discord.js b/broccolini-discord.js index c216460..d4c25df 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -17,7 +17,7 @@ const { handleDiscordReply } = require('./handlers/messages'); // Services & jobs const { sendTicketClosedEmail } = require('./services/gmail'); -const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels } = require('./services/tickets'); +const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); const { notifyAllStaffUnclaimed } = require('./services/staffNotifications'); const { registerCommands } = require('./commands/register'); const bosscordRoutes = require('./routes/bosscord'); @@ -37,6 +37,12 @@ function trackInterval(handle) { if (handle) activeIntervals.add(handle); return handle; } +// Track one-shot setTimeout handles so shutdown can clear them (e.g., scheduled restarts). +const activeTimeouts = new Set(); +function trackTimeout(handle) { + if (handle) activeTimeouts.add(handle); + return handle; +} /** * Update the Gmail poll interval at runtime. @@ -284,8 +290,15 @@ client.once('ready', async () => { reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)); trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000)); + resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e)); console.log('✓ Reconcile deleted ticket channels: every 1 hour'); + // Start in-memory Map sweeps (per-module) — keeps long-running processes bounded. + require('./services/patternStore').startSweeps(trackInterval); + require('./services/staffNotifications').startSweeps(trackInterval); + require('./services/tickets').startTicketsSweeps(trackInterval); + console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)'); + if (!CONFIG.STAFF_IDS.length) { console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.'); } @@ -329,8 +342,8 @@ internalApp.use('/internal', internalApi); let httpServer = null; let internalServer = null; if (CONFIG.INTERNAL_API_SECRET) { - internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => { - console.log(`[internalApi] listening on 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`); + 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 { console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.'); @@ -349,6 +362,10 @@ async function handleShutdown(signal) { try { clearInterval(handle); } catch (_) {} } activeIntervals.clear(); + for (const handle of activeTimeouts) { + try { clearTimeout(handle); } catch (_) {} + } + activeTimeouts.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 (_) {} @@ -366,6 +383,7 @@ module.exports = { client, setGmailPollInterval, clearGmailPollInterval, + trackTimeout, sendGmailReply, sendTicketClosedEmail, getNextTicketNumber, diff --git a/config.js b/config.js index a66b63b..49b8821 100644 --- a/config.js +++ b/config.js @@ -70,6 +70,11 @@ const DEFAULT_NOTIFICATION_THRESHOLDS = { chat_time: ['30m', '1h', '2h', '4h'] }; +function toInt(v, fallback) { + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : fallback; +} + function parseThresholdString(str) { const value = String(str || '').trim(); if (!value) return NaN; @@ -136,7 +141,7 @@ const CONFIG = { MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), LOGO_URL: process.env.LOGO_URL, SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support', - PORT: process.env.DISCORD_ONLY_PORT || 5000, + PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '
'), GAME_LIST: process.env.GAME_LIST || '', @@ -157,19 +162,19 @@ const CONFIG = { DISCORD_TRANSCRIPT_MESSAGE: process.env.DISCORD_TRANSCRIPT_MESSAGE || 'Your ticket **{channel_name}** has been closed. Here is your transcript. If you still need assistance, please open a new ticket.', DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.', AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true', - AUTO_CLOSE_AFTER_HOURS: parseInt(process.env.AUTO_CLOSE_AFTER_HOURS) || 72, + AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72), AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.', - GLOBAL_TICKET_LIMIT: parseInt(process.env.GLOBAL_TICKET_LIMIT) || 5, - TICKET_LIMIT_PER_CATEGORY: parseInt(process.env.TICKET_LIMIT_PER_CATEGORY) || 3, - RATE_LIMIT_TICKETS_PER_USER: parseInt(process.env.RATE_LIMIT_TICKETS_PER_USER) || 0, - RATE_LIMIT_WINDOW_MINUTES: parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 60, + GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5), + TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3), + RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0), + RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true', - REMINDER_AFTER_HOURS: parseInt(process.env.REMINDER_AFTER_HOURS) || 24, + REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24), REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', @@ -177,9 +182,9 @@ const CONFIG = { PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡', PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢', CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true', - CLAIM_TIMEOUT_HOURS: parseInt(process.env.CLAIM_TIMEOUT_HOURS) || 48, + CLAIM_TIMEOUT_HOURS: toInt(process.env.CLAIM_TIMEOUT_HOURS, 48), AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true', - AUTO_UNCLAIM_AFTER_HOURS: parseInt(process.env.AUTO_UNCLAIM_AFTER_HOURS) || 24, + AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24), ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true', USE_THREADS: process.env.USE_THREADS === 'true', THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null, @@ -189,11 +194,11 @@ const CONFIG = { BUTTON_EMOJI_CLOSE: process.env.BUTTON_EMOJI_CLOSE || '🔒', BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', - EMBED_COLOR_OPEN: parseInt(process.env.EMBED_COLOR_OPEN) || 0x00FF00, - EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000, - EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00, - EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600, - EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124, + EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00), + EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000), + EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00), + EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600), + EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124), STAFF_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat STAFF_EMOJIS: (() => { const raw = process.env.STAFF_EMOJIS; @@ -213,8 +218,8 @@ const CONFIG = { CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', ADMIN_ID: process.env.ADMIN_ID || null, STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null, - FORCE_CLOSE_TIMER: parseInt(process.env.FORCE_CLOSE_TIMER_SECONDS) || 60, - GMAIL_POLL_INTERVAL_MS: parseInt(process.env.GMAIL_POLL_INTERVAL_SECONDS || '30') * 1000, + FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), + GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null, AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null, RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, @@ -226,35 +231,35 @@ const CONFIG = { ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null, STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null, COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null, - PATTERN_USER_TICKET_THRESHOLD: parseInt(process.env.PATTERN_USER_TICKET_THRESHOLD) || 3, - PATTERN_GAME_TICKET_THRESHOLD: parseInt(process.env.PATTERN_GAME_TICKET_THRESHOLD) || 10, - PATTERN_STAFF_STALE_PING_THRESHOLD: parseInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD) || 5, - PATTERN_ESCALATION_THRESHOLD: parseInt(process.env.PATTERN_ESCALATION_THRESHOLD) || 3, - PATTERN_RAPID_CLOSE_SECONDS: parseInt(process.env.PATTERN_RAPID_CLOSE_SECONDS) || 120, - PATTERN_UNCLAIMED_HOURS: parseInt(process.env.PATTERN_UNCLAIMED_HOURS) || 4, - PATTERN_CHECK_INTERVAL_MINUTES: parseInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES) || 30, + PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3), + PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10), + PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5), + PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3), + PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120), + PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4), + PATTERN_CHECK_INTERVAL_MINUTES: toInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES, 30), ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null, ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null, SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null, - SURGE_TICKET_COUNT: parseInt(process.env.SURGE_TICKET_COUNT) || 10, - SURGE_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_TICKET_WINDOW_MINUTES) || 30, - SURGE_GAME_TICKET_COUNT: parseInt(process.env.SURGE_GAME_TICKET_COUNT) || 5, - SURGE_GAME_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES) || 30, - SURGE_STALE_COUNT: parseInt(process.env.SURGE_STALE_COUNT) || 8, - SURGE_STALE_HOURS: parseInt(process.env.SURGE_STALE_HOURS) || 2, - SURGE_NEEDS_RESPONSE_COUNT: parseInt(process.env.SURGE_NEEDS_RESPONSE_COUNT) || 5, - SURGE_NEEDS_RESPONSE_HOURS: parseInt(process.env.SURGE_NEEDS_RESPONSE_HOURS) || 1, - SURGE_UNCLAIMED_COUNT: parseInt(process.env.SURGE_UNCLAIMED_COUNT) || 5, - SURGE_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_UNCLAIMED_MINUTES) || 30, - SURGE_TIER3_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES) || 15, - SURGE_COOLDOWN_MINUTES: parseInt(process.env.SURGE_COOLDOWN_MINUTES) || 60, + SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10), + SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30), + SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5), + SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30), + SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8), + SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2), + SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5), + SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1), + SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5), + SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30), + SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15), + SURGE_COOLDOWN_MINUTES: toInt(process.env.SURGE_COOLDOWN_MINUTES, 60), CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean), - CHAT_ALERT_MESSAGE_COUNT: parseInt(process.env.CHAT_ALERT_MESSAGE_COUNT) || 5, - CHAT_ALERT_HOURS_WITHOUT_RESPONSE: parseInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE) || 2, - CHAT_ALERT_COOLDOWN_MINUTES: parseInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES) || 60, + CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5), + CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2), + CHAT_ALERT_COOLDOWN_MINUTES: toInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES, 60), STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean), - SURGE_NO_STAFF_COOLDOWN_MINUTES: parseInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES) || 30, - SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: parseInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) || 3, + SURGE_NO_STAFF_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30), + SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: toInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD, 3), STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', @@ -262,11 +267,12 @@ const CONFIG = { STAFF_THREAD_ROLE_ID: process.env.STAFF_THREAD_ROLE_ID || process.env.ROLE_ID_TO_PING || null, PIN_INITIAL_MESSAGE_ENABLED: process.env.PIN_INITIAL_MESSAGE_ENABLED === 'true', PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true', + TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true', PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', - SETTINGS_PORT: parseInt(process.env.SETTINGS_PORT) || 12752, + SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752), SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null, SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com', - INTERNAL_API_PORT: parseInt(process.env.INTERNAL_API_PORT) || 12753, + INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753), INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null, NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON), UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') diff --git a/docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md b/docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md index c53ff4f..00070cd 100644 --- a/docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md +++ b/docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md @@ -38,7 +38,7 @@ These are the files that give someone the fastest path to understanding the repo ### 8. [**services/tickets.js**](../services/tickets.js) - **Why:** Core ticket lifecycle and Discord channel/thread creation. -- **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming and Discord rate limit handling (2 renames per 10 min), ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename`, `makeTicketName`. +- **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming, ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename` (retained as an always-ok shim — see `utils/renamer.js` and `services/channelQueue.js` for actual rename handling and primary-bot fallback), `makeTicketName`. ### 9. [**handlers/buttons.js**](../handlers/buttons.js) - **Why:** Every button and ticket modal goes through here. @@ -167,7 +167,7 @@ Broccolini Bot is a **Node.js support-ticket bot** that connects **Gmail**, **Di ### Claim / Unclaim / Close - **Claim** (button or `/claim`) - Ticket is updated with `claimedBy` (user id or name). Channel may be renamed (respecting Discord’s 2 renames per 10 min). Claimed message is posted (template from CONFIG). + Ticket is updated with `claimedBy` (user id or name). Channel may be renamed via the secondary-bot path (`utils/renamer.js`), falling back to the primary bot on 401/403/429. Claimed message is posted (template from CONFIG). - **Unclaim** (button or `/unclaim`) `claimedBy` is cleared; channel rename and message as above. diff --git a/docs/features/IMPLEMENTATION_SUMMARY.md b/docs/features/IMPLEMENTATION_SUMMARY.md index 8e12ddb..12821f2 100644 --- a/docs/features/IMPLEMENTATION_SUMMARY.md +++ b/docs/features/IMPLEMENTATION_SUMMARY.md @@ -200,7 +200,7 @@ THREAD_PARENT_CHANNEL= - Optimized background job queries ### Rate Limit Handling -- Channel rename: 2 per 10 minutes per channel (Discord limit). When limit is reached, message: *Channel renamed too quickly. Try again \.* +- Channel rename: Discord enforces 2 per 10 minutes per channel **per bot**. Renames route through `utils/renamer.js` (RENAMER_BOT secondary token); on 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. - Modal submission handling - Autocomplete debouncing - Batch command registration diff --git a/docs/features/PHASE_FEATURES.md b/docs/features/PHASE_FEATURES.md index aa79716..ba72aaf 100644 --- a/docs/features/PHASE_FEATURES.md +++ b/docs/features/PHASE_FEATURES.md @@ -447,7 +447,7 @@ THREAD_PARENT_CHANNEL= ### Rate Limits - Channel creation: 50/day per guild -- Channel rename: 2 per 10 minutes per channel ([Discord docs](https://discord.com/developers/docs/topics/rate-limits)). When the limit is reached, the bot skips the rename and posts: *Channel renamed too quickly. Try again \.* +- Channel rename: Discord enforces 2 per 10 minutes per channel **per bot** ([Discord docs](https://discord.com/developers/docs/topics/rate-limits)). Renames route through `utils/renamer.js` (RENAMER_BOT secondary token); on 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. - Message edits: Be cautious with bulk operations --- diff --git a/docs/setup/MONGODB_SETUP.md b/docs/setup/MONGODB_SETUP.md index 189cc8c..4e1083b 100644 --- a/docs/setup/MONGODB_SETUP.md +++ b/docs/setup/MONGODB_SETUP.md @@ -66,8 +66,8 @@ const Transcript = mongoose.model('Transcript'); claimed_by: String (Discord user ID), escalated: Boolean (default: false), ticket_number: Number, - rename_count: Number (default: 0), - rename_window_start: Date + rename_count: Number (default: 0), // orphan: no longer read/written (see CLAUDE.md) + rename_window_start: Date // orphan: no longer read/written } ``` diff --git a/gmail-poll.js b/gmail-poll.js index 01e52ad..6adb1e5 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -160,9 +160,13 @@ async function poll(client) { if (ticketChan) { const truncatedFollowup = followupBody.slice(0, 1800); + // Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. await enqueueSend( ticketChan, - `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}` + { + content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`, + allowedMentions: { parse: ['roles'] } + } ); } else { // Check ticket limits before creating @@ -256,7 +260,8 @@ async function poll(client) { const welcomeMsg = await enqueueSend(ticketChan, { content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, embeds: [ticketInfoEmbed], - components: [buttons] + components: [buttons], + allowedMentions: { parse: ['roles'] } }); const { createStaffThread } = require('./services/staffThread'); @@ -311,7 +316,8 @@ async function poll(client) { } const truncated = firstBody.slice(0, 1900); - await enqueueSend(ticketChan, `**Message:**\n${truncated}`); + // Email body is attacker-controlled — no mentions may fire from its content. + await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } }); // Welcome message skipped for email tickets – the email body speaks for itself. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js. @@ -369,7 +375,7 @@ async function poll(client) { pollSuspended = true; const suspendMsg = 'Gmail OAuth token invalid or expired. Polling SUSPENDED — will not retry automatically. Re-authenticate to resume.'; console.error('[gmail-poll]', suspendMsg); - logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client); + logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {}); try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {} if (CONFIG.ADMIN_ID && !authErrorNotified) { authErrorNotified = true; @@ -379,7 +385,7 @@ async function poll(client) { totalErrors++; console.error('POLL ERROR:', e); - logError('Gmail poll', e, null, client); + logError('Gmail poll', e, null, client).catch(() => {}); } } finally { isPolling = false; diff --git a/handlers/accountinfo.js b/handlers/accountinfo.js index e1eb25d..171af2e 100644 --- a/handlers/accountinfo.js +++ b/handlers/accountinfo.js @@ -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, '']; diff --git a/handlers/buttons.js b/handlers/buttons.js index bfe3a9f..60d9da3 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -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, diff --git a/handlers/commands.js b/handlers/commands.js index 5480c1d..b8dd912 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -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)); diff --git a/models.js b/models.js index 1b10efe..e160d7d 100644 --- a/models.js +++ b/models.js @@ -817,7 +817,8 @@ const ticketSchema = new mongoose.Schema({ staffChannelId: String, parentCategoryId: String, unclaimedRemindersSent: { type: [Number], default: [] }, - lastMessageAuthorIsStaff: { type: Boolean, default: false } + lastMessageAuthorIsStaff: { type: Boolean, default: false }, + pendingDelete: { type: Boolean, default: false } }); ticketSchema.index({ status: 1, lastActivity: 1 }); ticketSchema.index({ senderEmail: 1, status: 1 }); diff --git a/routes/bosscord.js b/routes/bosscord.js index 0b8c72f..ddc9c08 100644 --- a/routes/bosscord.js +++ b/routes/bosscord.js @@ -10,7 +10,7 @@ const { getBot } = require('../api/bosscordClient'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); const { enqueueSend } = require('../services/channelQueue'); -const { extractRawEmail } = require('../utils'); +const { extractRawEmail, safeEqual } = require('../utils'); const { CONFIG } = require('../config'); const router = express.Router(); @@ -43,8 +43,9 @@ function authMiddleware(req, res, next) { } const auth = req.headers.authorization; const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null; - if (token !== key) { - return res.status(401).json({ error: 'Unauthorized' }); + // Identical response body for missing vs invalid token — don't tell a probe which state it's in. + if (!safeEqual(token, key)) { + return res.status(401).json({ error: 'unauthorized' }); } next(); } @@ -189,7 +190,9 @@ router.post('/tickets/:id/messages', express.json(), async (req, res) => { return res.status(404).json({ error: 'Discord channel not found' }); } const discordUser = req.body.displayName || 'bOSScord'; - await enqueueSend(channel, content); + // Content originates from the bOSScord web UI (staff-gated) but still crosses an HTTP boundary — + // allow explicit user/role mentions a staff member typed, block @everyone/@here. + await enqueueSend(channel, { content, allowedMentions: { parse: ['users', 'roles'] } }); if (!ticket.gmailThreadId.startsWith('discord-')) { try { diff --git a/routes/internalApi.js b/routes/internalApi.js index c2991e2..045b7b4 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -2,6 +2,7 @@ const express = require('express'); const rateLimit = require('express-rate-limit'); const { ChannelType } = require('discord.js'); const { CONFIG } = require('../config'); +const { safeEqual } = require('../utils'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { logSystem } = require('../services/debugLog'); const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); @@ -15,6 +16,7 @@ const { const router = express.Router(); +// Intentionally no trust-proxy: loopback-only; global rate-limit bucket. const internalLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, @@ -28,7 +30,7 @@ router.use(internalLimiter); // Middleware: verify internal secret router.use((req, res, next) => { const secret = req.headers['x-internal-secret']; - if (!CONFIG.INTERNAL_API_SECRET || secret !== CONFIG.INTERNAL_API_SECRET) { + if (!CONFIG.INTERNAL_API_SECRET || !safeEqual(secret, CONFIG.INTERNAL_API_SECRET)) { return res.status(401).json({ error: 'Unauthorized' }); } next(); @@ -136,10 +138,13 @@ router.post('/restart', express.json(), (req, res) => { const delay = new Date(scheduledFor).getTime() - Date.now(); if (delay <= 0) return res.status(400).json({ error: 'Scheduled time is in the past' }); if (scheduledRestart) clearTimeout(scheduledRestart); - scheduledRestart = setTimeout(() => { + // Lazy require: broccolini-discord.js requires this file at module scope before its exports are populated. + const { trackTimeout } = require('../broccolini-discord'); + scheduledRestart = trackTimeout(setTimeout(() => { console.log('[restart] Scheduled restart firing...'); process.exit(0); - }, delay); + }, delay)); + if (scheduledRestart && typeof scheduledRestart.unref === 'function') scheduledRestart.unref(); res.json({ ok: true, mode, scheduledFor, delayMs: delay }); return; } diff --git a/services/channelQueue.js b/services/channelQueue.js index e1a3cdf..3a8fad5 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -129,4 +129,32 @@ function enqueueSend(channel, ...args) { return next; } -module.exports = { enqueueRename, enqueueMove, enqueueSend }; +// Delete a channel only after every in-flight send/rename/move on it has drained. +// Chains on both renameChains and sendChains so "pending send in-flight, delete +// racing it" can no longer hit Discord's unknown-channel 10003. +function enqueueDelete(channel) { + if (!channel || typeof channel.delete !== 'function') { + return Promise.reject(new Error('enqueueDelete: invalid channel')); + } + const renameEntry = renameChains.get(channel.id); + const prevRename = renameEntry ? renameEntry.chain : Promise.resolve(); + const prevSend = sendChains.get(channel.id) || Promise.resolve(); + + const next = Promise.all([ + prevRename.catch(() => {}), + prevSend.catch(() => {}) + ]).then(() => channel.delete().catch(() => {})); + + if (renameEntry) renameEntry.chain = next; + sendChains.set(channel.id, next); + + next.finally(() => { + if (renameEntry && renameChains.get(channel.id) === renameEntry && renameEntry.chain === next) { + renameChains.delete(channel.id); + } + if (sendChains.get(channel.id) === next) sendChains.delete(channel.id); + }); + return next; +} + +module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete }; diff --git a/services/configPersistence.js b/services/configPersistence.js index 5047850..3bdf66e 100644 --- a/services/configPersistence.js +++ b/services/configPersistence.js @@ -7,60 +7,153 @@ const ENV_PATH = process.env.ENV_FILE ? path.resolve(process.env.ENV_FILE) : path.resolve(process.cwd(), '.env'); +/** + * Serialize a runtime value for .env storage. + * + * Default container: backticks. Under dotenv v17, backtick-wrapped values are + * preserved verbatim (literal newlines and inner quotes/backslashes all round-trip + * without escape processing), which is the only container that survives quotes + * AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and + * `\\` stay literal, so quoted-with-escapes doesn't round-trip. + * + * Fallback for values that themselves contain a backtick (vanishingly rare in + * env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`, + * `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot + * because dotenv v17 won't reverse those. Flagged in the key-count verification + * at the bottom of writeEnvFile; the combination is unreachable via the UI. + */ +function encodeEnvValue(v) { + const s = String(v == null ? '' : v); + if (!s.includes('`')) return '`' + s + '`'; + const escaped = s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + return `"${escaped}"`; +} + +/** + * Decode a raw .env value. Backtick and double-quote containers supported; + * unquoted values pass through (hand-edited .env back-compat). + */ +function decodeEnvValue(raw) { + if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) { + return raw.slice(1, -1); + } + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + const inner = raw.slice(1, -1); + let out = ''; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '\\' && i + 1 < inner.length) { + const next = inner[i + 1]; + if (next === 'n') out += '\n'; + else if (next === 'r') out += '\r'; + else if (next === 't') out += '\t'; + else if (next === '"') out += '"'; + else if (next === '\\') out += '\\'; + else out += inner[i] + next; + i++; + } else { + out += inner[i]; + } + } + return out; + } + return raw; +} + /** * Read the current .env file and parse into a key->value Map. + * Backtick-wrapped values may span multiple physical lines (dotenv behavior); + * this reader joins continuation lines until the closing backtick is found. + * Double-quoted values are decoded (`\n`/`\r` escapes processed); + * unquoted values pass through. */ function readEnvFile() { if (!fs.existsSync(ENV_PATH)) return new Map(); const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); const map = new Map(); - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const idx = line.indexOf('='); if (idx === -1) continue; const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - map.set(key, value); + let value = line.slice(idx + 1); + if (value.trimStart().startsWith('`')) { + let btCount = (value.match(/`/g) || []).length; + while (btCount < 2 && i + 1 < lines.length) { + i++; + value += '\n' + lines[i]; + btCount = (value.match(/`/g) || []).length; + } + } + map.set(key, decodeEnvValue(value.trim())); } return map; } /** * Write a Map of key->value back to the .env file, - * preserving comments and blank lines. + * preserving comments and blank lines. Values are encoded via encodeEnvValue. + * + * After write, re-reads the file and throws if the key count doesn't match the + * expected count — catches truncation or corrupted-quote escaping. */ function writeEnvFile(updates) { + const expected = updates.size; + if (!fs.existsSync(ENV_PATH)) { const lines = []; - for (const [k, v] of updates) lines.push(`${k}=${v}`); + for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`); fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8'); - return; - } + } else { + const raw = fs.readFileSync(ENV_PATH, 'utf8'); + const lines = raw.split('\n'); + const written = new Set(); + const result = []; - const raw = fs.readFileSync(ENV_PATH, 'utf8'); - const lines = raw.split('\n'); - const written = new Set(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; } + const idx = line.indexOf('='); + if (idx === -1) { result.push(line); continue; } + const key = line.slice(0, idx).trim(); - const result = lines.map(line => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return line; - const idx = line.indexOf('='); - if (idx === -1) return line; - const key = line.slice(0, idx).trim(); - if (updates.has(key)) { - written.add(key); - return `${key}=${updates.get(key)}`; + const spanStart = i; + let valueSoFar = line.slice(idx + 1); + if (valueSoFar.trimStart().startsWith('`')) { + let btCount = (valueSoFar.match(/`/g) || []).length; + while (btCount < 2 && i + 1 < lines.length) { + i++; + valueSoFar += '\n' + lines[i]; + btCount = (valueSoFar.match(/`/g) || []).length; + } + } + + if (updates.has(key)) { + written.add(key); + result.push(`${key}=${encodeEnvValue(updates.get(key))}`); + } else { + for (let j = spanStart; j <= i; j++) result.push(lines[j]); + } } - return line; - }); - // Append any new keys not already in the file - for (const [k, v] of updates) { - if (!written.has(k)) result.push(`${k}=${v}`); + for (const [k, v] of updates) { + if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`); + } + + fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8'); } - fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8'); + const roundtrip = readEnvFile(); + if (roundtrip.size !== expected) { + throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`); + } } /** diff --git a/services/gmail.js b/services/gmail.js index 87ef9e8..9c1f6a5 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -5,6 +5,10 @@ const { google } = require('googleapis'); const { CONFIG } = require('../config'); const { extractRawEmail, escapeHtml } = require('../utils'); const { getStaffSignatureBlocks } = require('./staffSignature'); +const { logError } = require('./debugLog'); + +function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); } +const EMAIL_RE = /^[^@\s]+@[^@\s]+$/; function getGmailClient() { const auth = new google.auth.OAuth2( @@ -20,8 +24,12 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { const gmail = getGmailClient(); // Send to the ticket sender (customer), not derived from thread (which can be support) - const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase(); + const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; + if (!EMAIL_RE.test(recipientEmail)) { + logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); + return; + } let subjectHeader = ticket.subject || 'Support'; let msgId = null; @@ -35,13 +43,13 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { if (lastMsg?.payload?.headers) { const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; if (subj) subjectHeader = subj; - msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value; + msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); } } catch (_) { /* use ticket.subject and no In-Reply-To if thread fetch fails */ } - const finalSubject = `${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`; + const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`); const utf8Subject = `=?utf-8?B?${Buffer.from( finalSubject ).toString('base64')}?=`; @@ -72,7 +80,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { `; const rawHeaders = [ - `From: ${CONFIG.MY_EMAIL}`, + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${recipientEmail}`, `Subject: ${utf8Subject}`, msgId ? `In-Reply-To: ${msgId}` : '', @@ -113,8 +121,12 @@ const StaffSignature = mongoose.model('StaffSignature'); async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) { try { const gmail = getGmailClient(); - const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase(); + const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; + if (!EMAIL_RE.test(recipientEmail)) { + logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); + return; + } let subjectHeader = ticket.subject || 'Support'; let msgId = null; @@ -128,11 +140,11 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro if (lastMsg?.payload?.headers) { const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; if (subj) subjectHeader = subj; - msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value; + msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); } } catch (_) {} - const finalSubject = subjectLine || subjectHeader; + const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader); const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`; const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support'); const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '
'); @@ -169,7 +181,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro `; const rawHeaders = [ - `From: ${CONFIG.MY_EMAIL}`, + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${recipientEmail}`, `Subject: ${utf8Subject}`, msgId ? `In-Reply-To: ${msgId}` : '', @@ -216,8 +228,16 @@ async function sendGmailReply( ) { const gmail = getGmailClient(); + const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); + if (!EMAIL_RE.test(safeRecipient)) { + logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); + return null; + } + const safeMessageId = sanitizeHeaderValue(messageId); + const safeSubject = sanitizeHeaderValue(`Re: ${subject}`); + const utf8Subject = `=?utf-8?B?${Buffer.from( - `Re: ${subject}` + safeSubject ).toString('base64')}?=`; const safeUser = escapeHtml(discordUser); const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); @@ -229,6 +249,7 @@ async function sendGmailReply( signatureBlocks = await getStaffSignatureBlocks(userId); } + // signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here. const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; const safeStaffSigText = signatureBlocks.text; const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); @@ -264,11 +285,11 @@ async function sendGmailReply( plainBody.push(companySignatureText); const raw = Buffer.from([ - `From: ${CONFIG.MY_EMAIL}`, - `To: ${recipientEmail}`, + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, + `To: ${safeRecipient}`, `Subject: ${utf8Subject}`, - messageId ? `In-Reply-To: ${messageId}` : '', - messageId ? `References: ${messageId}` : '', + safeMessageId ? `In-Reply-To: ${safeMessageId}` : '', + safeMessageId ? `References: ${safeMessageId}` : '', 'MIME-Version: 1.0', 'Content-Type: multipart/alternative; boundary="' + boundary + '"', '', diff --git a/services/patternStore.js b/services/patternStore.js index 3b00041..97c3314 100644 --- a/services/patternStore.js +++ b/services/patternStore.js @@ -83,12 +83,15 @@ function onWeeklyReset(fn) { const firedThresholds = new Map(); // key -> window type used for threshold clearing ("today" | "week" | "month") const firedThresholdWindows = new Map(); +// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset. +const firedThresholdLastSeen = new Map(); function clearFiredThresholdsForWindow(windowType) { for (const [key, mappedWindowType] of firedThresholdWindows.entries()) { if (mappedWindowType === windowType) { firedThresholds.delete(key); firedThresholdWindows.delete(key); + firedThresholdLastSeen.delete(key); } } } @@ -98,6 +101,7 @@ function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) { if (!['today', 'week', 'month'].includes(windowType)) return null; firedThresholdWindows.set(key, windowType); + firedThresholdLastSeen.set(key, Date.now()); const firedForKey = firedThresholds.get(key) || new Set(); const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); @@ -148,18 +152,49 @@ function clearEscalating(key) { escalatingCooldowns.delete(key); } -const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000; -const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; +const SWEEP_TTL_MS = 48 * 60 * 60 * 1000; +const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; -function cleanupStaleEscalatingCooldowns() { - const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS; +function cleanupStaleEscalatingCooldowns(now = Date.now()) { + const cutoff = now - SWEEP_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?.(); +// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS. +// firedThresholds/firedThresholdWindows are cleared by windowType-resets; +// this sweep covers keys whose window never resets under load. +function sweepPatternStore(now = Date.now()) { + const cutoff = now - SWEEP_TTL_MS; + for (const [key, ts] of cooldowns.entries()) { + if (ts < cutoff) cooldowns.delete(key); + } + for (const [key, ts] of staffLastSeen.entries()) { + if (ts < cutoff) staffLastSeen.delete(key); + } + cleanupStaleEscalatingCooldowns(now); + for (const [key, ts] of firedThresholdLastSeen.entries()) { + if (ts < cutoff) { + firedThresholds.delete(key); + firedThresholdWindows.delete(key); + firedThresholdLastSeen.delete(key); + } + } +} + +/** + * Register the module's sweep on the given trackInterval function. + * Called once from the ready handler. Interval is unref'd so it never + * blocks shutdown; trackInterval ensures handleShutdown clears it. + */ +function startSweeps(trackInterval) { + const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS); + if (typeof handle.unref === 'function') handle.unref(); + if (typeof trackInterval === 'function') trackInterval(handle); + return handle; +} function scheduleDailyReset() { setTimeout(() => { @@ -243,5 +278,9 @@ module.exports = { isOnCooldown, updateStaffLastSeen, getStaffLastSeen, - isStaffRecentlyActive + isStaffRecentlyActive, + startSweeps, + sweepPatternStore, + // test-only exports + _internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS } }; diff --git a/services/staffChannel.js b/services/staffChannel.js index 9507ca5..02adc7d 100644 --- a/services/staffChannel.js +++ b/services/staffChannel.js @@ -79,6 +79,7 @@ async function deleteStaffChannel(guild, staffChannelId) { if (!staffChannelId) return; try { const chan = await guild.channels.fetch(staffChannelId).catch(() => null); + // TODO(queue-migrate): raw channel.delete bypasses channelQueue (enqueueDelete) — if a staff-channel send is in-flight, this can race it. if (chan) await chan.delete(); } catch (e) { console.error('Failed to delete staff channel:', e); diff --git a/services/staffNotifications.js b/services/staffNotifications.js index 3617993..17dd380 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -25,6 +25,23 @@ const StaffNotification = mongoose.model('StaffNotification'); // In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp const replyCooldowns = new Map(); +const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000; +const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; + +function sweepReplyCooldowns(now = Date.now()) { + const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS; + for (const [key, ts] of replyCooldowns.entries()) { + if (ts < cutoff) replyCooldowns.delete(key); + } +} + +function startSweeps(trackInterval) { + const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS); + if (typeof handle.unref === 'function') handle.unref(); + if (typeof trackInterval === 'function') trackInterval(handle); + return handle; +} + /** * Notify the claiming staff member when a non-staff user replies. * Respects the staff member's cooldownHours setting (default 1h). @@ -72,11 +89,13 @@ async function notifyAllStaffUnclaimed(client) { const sorted = [...thresholds].sort((a, b) => a - b); const now = Date.now(); + // Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500 + // gets drained in subsequent 30-minute ticks rather than one long run. const unclaimedTickets = await Ticket.find({ status: 'open', claimedBy: null, createdAt: { $ne: null } - }).lean(); + }).sort({ createdAt: 1 }).limit(500).lean(); if (unclaimedTickets.length === 0) return; @@ -103,8 +122,7 @@ async function notifyAllStaffUnclaimed(client) { const channelName = ticket.discordThreadId ? `<#${ticket.discordThreadId}>` : `ticket #${ticket.ticketNumber}`; - const hoursAgo = Math.floor(ageHours); - const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`; + const alertMsg = `[${highest}h+ unclaimed] ${channelName}`; for (const rec of staffRecords) { const chan = await guild.channels.fetch(rec.channelId).catch(() => null); @@ -122,4 +140,10 @@ async function notifyAllStaffUnclaimed(client) { } } -module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed }; +module.exports = { + notifyStaffOfReply, + notifyAllStaffUnclaimed, + startSweeps, + sweepReplyCooldowns, + _internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS } +}; diff --git a/services/staffSignature.js b/services/staffSignature.js index fb972f0..b465fd4 100644 --- a/services/staffSignature.js +++ b/services/staffSignature.js @@ -1,4 +1,5 @@ const { mongoose } = require('../db-connection'); +const { escapeHtml } = require('../utils'); /** * Returns { text, html } for a staff member's signature. @@ -17,7 +18,7 @@ async function getStaffSignatureBlocks(userId) { if (sig.tagline) lines.push(sig.tagline); const text = lines.join('\n'); - const html = lines.map(l => `
${l}
`).join(''); + const html = lines.map(l => `
${escapeHtml(l)}
`).join(''); return { text, html }; } diff --git a/services/tickets.js b/services/tickets.js index 7c7aba2..20ee603 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -7,7 +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 { enqueueSend, enqueueDelete } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); const TicketCounter = mongoose.model('TicketCounter'); @@ -104,6 +104,26 @@ function minutesFromMs(ms) { const ticketCreationByUser = new Map(); // userId -> { count, resetAt } +const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000; +const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; + +function sweepTicketCreationByUser(now = Date.now()) { + // An entry is stale when its window has been expired long enough that no + // legitimate rate-limit decision would still consult it. resetAt is a future + // ms timestamp when the window ends; cutoff is 48h past that. + const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS; + for (const [key, entry] of ticketCreationByUser.entries()) { + if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key); + } +} + +function startTicketsSweeps(trackInterval) { + const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS); + if (typeof handle.unref === 'function') handle.unref(); + if (typeof trackInterval === 'function') trackInterval(handle); + return handle; +} + /** * Check if the user can create a ticket (rate limit). If allowed, consumes one slot. * @param {string} userId - Discord user ID @@ -436,10 +456,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) { if (!CONFIG.AUTO_CLOSE_ENABLED) return; const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); + // Bounded per-tick so a huge backlog drains across successive hourly runs. const staleTickets = await withRetry(() => Ticket.find({ status: 'open', lastActivity: { $lt: cutoffTime, $ne: null } - }).lean()); + }).sort({ createdAt: 1 }).limit(500).lean()); let checked = 0, closed = 0; for (const ticket of staleTickets) { @@ -452,14 +473,24 @@ async function checkAutoClose(client, sendTicketClosedEmail) { if (channel) { await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); + // Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be + // resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete + // resolves; if the doc is gone the unset is a no-op. await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed' } } + { $set: { status: 'closed', pendingDelete: true } } )); await sendTicketClosedEmail(ticket, 'Auto-Close System'); - setTimeout(() => channel.delete().catch(() => {}), 5000); + setTimeout(() => { + enqueueDelete(channel).then(() => { + withRetry(() => Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $unset: { pendingDelete: '' } } + )).catch(() => {}); + }).catch(() => {}); + }, 5000); closed++; } } catch (error) { @@ -551,10 +582,11 @@ async function reconcileDeletedTicketChannels(client) { const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); if (!guild) return { checked: 0, reconciled: 0 }; + // Bounded per-tick; a larger backlog drains in subsequent hourly runs. const openTickets = await Ticket.find({ status: 'open', discordThreadId: { $ne: null } - }).lean(); + }).sort({ createdAt: 1 }).limit(500).lean(); let checked = 0, reconciled = 0; for (const ticket of openTickets) { @@ -582,9 +614,39 @@ async function reconcileDeletedTicketChannels(client) { return { checked, reconciled }; } +/** + * Resume deletes that were pending when the bot last shut down. Called once + * from the ready handler. Clears the flag regardless of fetch result so a + * stale flag (e.g. channel already gone) can't loop. + */ +async function resumePendingDeletes(client) { + const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []); + if (!pending.length) return 0; + let resumed = 0; + for (const ticket of pending) { + try { + const guild = client.guilds.cache.first(); + if (guild && ticket.discordThreadId) { + const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); + if (channel) { + enqueueDelete(channel).catch(() => {}); + resumed++; + } + } + Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $unset: { pendingDelete: '' } } + ).catch(() => {}); + } catch (e) { + console.error('resumePendingDeletes error:', e); + } + } + logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {}); + return resumed; +} + module.exports = { getNextTicketNumber, - pickTicketCategoryId, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, @@ -605,5 +667,9 @@ module.exports = { checkAutoClose, checkReminders, checkAutoUnclaim, - reconcileDeletedTicketChannels + reconcileDeletedTicketChannels, + resumePendingDeletes, + startTicketsSweeps, + sweepTicketCreationByUser, + _internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS } }; diff --git a/settings-site/docker-compose.yml b/settings-site/docker-compose.yml index e523286..285b6d5 100644 --- a/settings-site/docker-compose.yml +++ b/settings-site/docker-compose.yml @@ -4,6 +4,11 @@ services: container_name: broccolini-settings restart: unless-stopped env_file: ../.env + environment: + # Node must bind all-interfaces inside the container so Docker's DNAT + # from the host-side Tailscale publish below can reach it. The Tailscale + # restriction is enforced by the `ports:` binding, not by Node. + SETTINGS_BIND_HOST: "0.0.0.0" ports: - "100.114.205.53:12752:12752" networks: diff --git a/settings-site/server.js b/settings-site/server.js index 9fabfda..ca98647 100644 --- a/settings-site/server.js +++ b/settings-site/server.js @@ -6,6 +6,14 @@ const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const { doubleCsrf } = require('csrf-csrf'); const path = require('path'); +const crypto = require('crypto'); + +// Mirror of safeEqual() in ../utils.js — duplicated here because the settings-site Docker build context excludes the parent dir. +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); +} const app = express(); const PORT = parseInt(process.env.SETTINGS_PORT) || 12752; @@ -57,7 +65,7 @@ app.use(express.urlencoded({ extended: true, limit: '64kb' })); app.use(session({ secret: SESSION_SECRET, resave: false, - saveUninitialized: true, + saveUninitialized: false, cookie: { httpOnly: true, secure: IS_PROD, @@ -111,7 +119,12 @@ async function callBot(method, apiPath, body) { }, body: body ? JSON.stringify(body) : undefined }); - return res.json(); + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return { error: 'bad_upstream', status: res.status, body: text.slice(0, 500) }; + } } function proxy(method, botPath) { @@ -162,7 +175,7 @@ app.get('/login', (req, res) => { }); app.post('/login', loginLimiter, (req, res) => { - if (req.body.password === ADMIN_PASSWORD) { + if (safeEqual(req.body.password, ADMIN_PASSWORD)) { req.session.authed = true; return res.json({ ok: true }); } @@ -197,6 +210,14 @@ app.use((err, req, res, next) => { next(err); }); -app.listen(PORT, '0.0.0.0', () => { - console.log(`[settings] running on port ${PORT}`); +// Default bind is loopback. Production runs in Docker with +// docker-compose.yml publishing `100.114.205.53:12752:12752` — that host-side +// publish is what restricts ingress to the Tailscale IP, and the container +// sets SETTINGS_BIND_HOST=0.0.0.0 so Docker's DNAT can reach Node. Caddy lives +// on a separate host (rustdesk droplet) and reaches this box over Tailscale, +// so 127.0.0.1 is not reachable from Caddy in prod; do not change that +// default without updating docker-compose.yml in lockstep. +const BIND_HOST = process.env.SETTINGS_BIND_HOST || '127.0.0.1'; +app.listen(PORT, BIND_HOST, () => { + console.log(`[settings] running on ${BIND_HOST}:${PORT}`); }); diff --git a/utils.js b/utils.js index 95a7e81..960d05f 100644 --- a/utils.js +++ b/utils.js @@ -2,8 +2,24 @@ * Pure utility functions – text processing, date formatting, game detection, * priority helpers, template variables. */ +const crypto = require('crypto'); const { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = 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 = @@ -116,40 +132,37 @@ function stripEmailQuotes(text) { 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; - - const patterns = [ - /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 - ]; - let result = text; - - for (const re of patterns) { - const rx = new RegExp(`\\n*${re.source}\\s*`, 'i'); + for (const rx of MOBILE_FOOTER_REGEXES) { result = result.replace(rx, ''); } - return result; } @@ -186,22 +199,25 @@ const getFormattedDate = () => { }; // --- GAME DETECTION --- +// Map 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 game of GAME_NAMES) { - const g = game.toLowerCase(); - const re = new RegExp(`\\b${escapeRegex(g)}\\b`, 'i'); - if (re.test(txt)) return game; + for (const { re, canonical } of GAME_DETECTION.values()) { + if (re.test(txt)) return canonical; } - - for (const [alias, fullName] of Object.entries(GAME_ALIASES)) { - const a = alias.toLowerCase(); - const re = new RegExp(`\\b${escapeRegex(a)}\\b`, 'i'); - if (re.test(txt)) return fullName; - } - return 'Not Mentioned'; }; @@ -378,6 +394,8 @@ module.exports = { BLOCK_TAG_REGEX, escapeRegex, escapeHtml, + safeEqual, + isStaff, decodeHtmlEntities, htmlToTextWithBlocks, decodeGmailData,