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'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); const { getAllState: getNotificationState, setKeyEnabled, setCategoryEnabled, setMasterEnabled } = require('../services/notificationEnabled'); const router = express.Router(); // Intentionally no trust-proxy: loopback-only; global rate-limit bucket. const internalLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' } }); router.use(internalLimiter); // Middleware: verify internal secret router.use((req, res, next) => { const secret = req.headers['x-internal-secret']; if (!CONFIG.INTERNAL_API_SECRET || !safeEqual(secret, CONFIG.INTERNAL_API_SECRET)) { return res.status(401).json({ error: 'Unauthorized' }); } next(); }); // GET /config — return all current .env values (redacted secrets) router.get('/config', (req, res) => { const map = readAllConfig(); const obj = {}; const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD']; for (const [k, v] of map) { obj[k] = REDACTED.includes(k) ? '••••••••' : v; } res.json(obj); }); // POST /config — apply config updates. ALLOWED_CONFIG_KEYS comes from // services/configSchema (the canonical schema + allowlist). router.post('/config', express.json(), async (req, res) => { const updates = req.body; if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { return res.status(400).json({ error: 'Invalid body' }); } const rejected = Object.keys(updates).filter(k => !ALLOWED_CONFIG_KEYS.has(k)); if (rejected.length > 0) { return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); } const result = applyConfigUpdates(updates); const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', '); await logSystem('Config updated via settings UI', [ { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false }, { name: 'Errors', value: errorSummary || 'none', inline: false } ]).catch(() => {}); // Partial success stays 200 so the client can still apply the successful keys. // Only 400 when every submitted key failed validation (i.e. the save did nothing). const totalSubmitted = Object.keys(updates).length; const allFailed = totalSubmitted > 0 && result.applied.length === 0 && result.errors.length > 0; res.status(allFailed ? 400 : 200).json(result); }); // GET /discord/guild — return guild info for smart dropdowns router.get('/discord/guild', async (req, res) => { try { const client = require('../api/bosscordClient').getBot(); if (!client) return res.status(503).json({ error: 'Bot not ready' }); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); if (!guild) return res.status(404).json({ error: 'Guild not found' }); await guild.members.fetch().catch(() => {}); const CHANNEL_TYPES = [ ChannelType.GuildText, ChannelType.GuildCategory, ChannelType.GuildAnnouncement, ChannelType.GuildForum ]; const channels = guild.channels.cache .filter(c => CHANNEL_TYPES.includes(c.type)) .map(c => ({ id: c.id, name: c.name, type: c.type, parentId: c.parentId })) .sort((a, b) => a.name.localeCompare(b.name)); const roles = guild.roles.cache .filter(r => !r.managed && r.id !== guild.id) .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) .sort((a, b) => b.position - a.position); const members = guild.members.cache .filter(m => !m.user.bot) .map(m => ({ id: m.id, username: m.user.username, displayName: m.displayName, avatar: m.user.displayAvatarURL({ size: 32 }) })) .sort((a, b) => a.displayName.localeCompare(b.displayName)); const categories = guild.channels.cache .filter(c => c.type === ChannelType.GuildCategory) .map(c => ({ id: c.id, name: c.name })) .sort((a, b) => a.name.localeCompare(b.name)); res.json({ channels, roles, members, categories }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /restart — restart the bot process let scheduledRestart = null; router.post('/restart', express.json(), (req, res) => { const { mode, scheduledFor } = req.body; if (mode === 'immediate') { res.json({ ok: true, mode }); setTimeout(() => { console.log('[restart] Restarting bot process...'); process.exit(0); // Docker/systemd will restart }, 1500); return; } if (mode === 'scheduled' && scheduledFor) { 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); // 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)); if (scheduledRestart && typeof scheduledRestart.unref === 'function') scheduledRestart.unref(); res.json({ ok: true, mode, scheduledFor, delayMs: delay }); return; } if (mode === 'cancel_scheduled') { if (scheduledRestart) { clearTimeout(scheduledRestart); scheduledRestart = null; } res.json({ ok: true, cancelled: true }); return; } if (mode === 'pending') { res.json({ ok: true, mode: 'pending', note: 'Restart required on next manual restart' }); return; } res.status(400).json({ error: 'Invalid mode' }); }); router.get('/restart/status', (req, res) => { res.json({ scheduledRestart: !!scheduledRestart }); }); // GET /notifications/alerts — canonical bot-side notification alert catalog router.get('/notifications/alerts', (req, res) => { res.json(NOTIFICATION_REGISTRY); }); // GET /notifications/state — Phase 9: master flag + per-key enable map router.get('/notifications/state', (req, res) => { res.json(getNotificationState()); }); // POST /notifications/toggle — Phase 9: mutate one of {master, category, key} // // Body shapes (exactly one of these must be used): // { master: true, enabled: } // { category: , enabled: } // { key: , enabled: } // // Mutates CONFIG in memory via notificationEnabled, then persists through // applyConfigUpdates so the value passes schema validation and ends up in .env. router.post('/notifications/toggle', express.json(), async (req, res) => { const body = req.body; if (!body || typeof body !== 'object' || Array.isArray(body)) { return res.status(400).json({ error: 'Invalid body' }); } if (typeof body.enabled !== 'boolean') { return res.status(400).json({ error: '`enabled` must be boolean' }); } const hasMaster = Object.prototype.hasOwnProperty.call(body, 'master'); const hasCategory = Object.prototype.hasOwnProperty.call(body, 'category'); const hasKey = Object.prototype.hasOwnProperty.call(body, 'key'); const specifiedCount = Number(hasMaster) + Number(hasCategory) + Number(hasKey); if (specifiedCount !== 1) { return res.status(400).json({ error: 'Specify exactly one of: master, category, key' }); } let updates; if (hasMaster) { setMasterEnabled(body.enabled); updates = { NOTIFICATIONS_MASTER_ENABLED: body.enabled }; } else if (hasCategory) { if (typeof body.category !== 'string' || !Object.prototype.hasOwnProperty.call(NOTIFICATION_REGISTRY, body.category)) { return res.status(400).json({ error: 'Unknown category' }); } const newJson = setCategoryEnabled(body.category, body.enabled); updates = { NOTIFICATION_ENABLED_JSON: newJson }; } else { if (typeof body.key !== 'string' || !body.key) { return res.status(400).json({ error: '`key` must be a non-empty string' }); } const newJson = setKeyEnabled(body.key, body.enabled); updates = { NOTIFICATION_ENABLED_JSON: newJson }; } const result = applyConfigUpdates(updates); if (result.errors.length > 0) { return res.status(500).json({ error: 'Persistence failed', details: result.errors }); } res.json({ state: getNotificationState() }); }); // POST /gmail/reload — hot-swap Gmail OAuth creds after weekly reauth without // restarting the process. Reads REFRESH_TOKEN from .env via configPersistence, // probes Google with users.getProfile, and on success clears pollSuspended and // re-installs the poll interval. On failure returns 400 with Google's error so // the operator can see why (e.g. still invalid_grant). router.post('/gmail/reload', express.json(), async (req, res) => { const { reloadGmailClient } = require('../services/gmail'); const { setPollSuspended } = require('../gmail-poll'); try { const { emailAddress } = await reloadGmailClient(); setPollSuspended(false); // Lazy require — same reason as /restart above (module scope cycle). const parent = require('../broccolini-discord'); if (parent.setGmailPollInterval) { parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS); } await logSystem('Gmail OAuth reloaded', [ { name: 'Account', value: emailAddress, inline: false } ]).catch(() => {}); res.json({ ok: true, email: emailAddress }); } catch (err) { const oauthError = err && err.response && err.response.data && err.response.data.error; res.status(400).json({ ok: false, error: oauthError || err.code || err.message || 'reload failed' }); } }); // Expose the allowlist for the Phase 8 schema smoke test. Attached to the // router function object; doesn't show up as a route. router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS); module.exports = router;