265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
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: <bool> }
|
|
// { category: <str>, enabled: <bool> }
|
|
// { key: <str>, enabled: <bool> }
|
|
//
|
|
// 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;
|