const express = require('express'); const router = express.Router(); const db = require('../../database'); const { getMinecraftServers } = require('../../panel/discovery'); const { readServerProperties, writeWhitelistFile } = require('../../panel/files'); const { reloadWhitelistCommand } = require('../../panel/commands'); const pterodactyl = require('../../services/pterodactyl'); const uptimeKuma = require('../../services/uptimeKuma'); const { ChannelType, PermissionFlagsBits } = require('discord.js'); // In-memory cache for RV low-bandwidth operations let serverCache = { data: null, lastFetch: 0 }; const CACHE_TTL = 60000; // 60 seconds // Cache for Discord channels (refresh less frequently) let discordChannelCache = { channels: null, lastFetch: 0 }; const DISCORD_CACHE_TTL = 300000; // 5 minutes // Staff/admin role names for channel permissions const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst']; // Forum tags for new server forums const STANDARD_FORUM_TAGS = [ { name: 'Builds', emoji: '🏗️' }, { name: 'Help', emoji: '❓' }, { name: 'Suggestion', emoji: '💡' }, { name: 'Bug Report', emoji: '🐛' }, { name: 'Achievement', emoji: '🎉' }, { name: 'Guide', emoji: '📖' } ]; /** * Get Discord channels from cache or fetch fresh */ async function getDiscordChannels(client) { const now = Date.now(); if (discordChannelCache.channels && (now - discordChannelCache.lastFetch < DISCORD_CACHE_TTL)) { return discordChannelCache.channels; } const guild = client.guilds.cache.get(process.env.GUILD_ID); if (!guild) return []; const channels = guild.channels.cache.map(ch => ({ id: ch.id, name: ch.name, type: ch.type, parentId: ch.parentId })); discordChannelCache = { channels, lastFetch: now }; return channels; } /** * Invalidate Discord channel cache (after create/delete) */ function invalidateDiscordCache() { discordChannelCache = { channels: null, lastFetch: 0 }; } /** * Check which Discord channels exist for a server. * Uses short_name from server_config instead of deriving from full name. * Returns { missing, found, complete, unconfigured } */ function checkServerChannels(shortName, serverName, allChannels) { if (!shortName) { return { missing: [], found: [], complete: false, unconfigured: true }; } // Voice channel uses the display name (before " - " subtitle) const voiceDisplayName = serverName .split(' - ')[0] .replace(/\s*\([^)]*\)\s*/g, '') .trim(); const expectedChannels = [ { name: `${shortName}-chat`, type: 'text', label: 'Chat' }, { name: `${shortName}-in-game`, type: 'text', label: 'In-Game' }, { name: `${shortName}-forum`, type: 'forum', label: 'Forum' }, { name: `${shortName}-status`, type: 'text', label: 'Status' }, { name: voiceDisplayName, type: 'voice', label: 'Voice' } ]; const missing = []; const found = []; for (const expected of expectedChannels) { let exists = false; if (expected.type === 'voice') { exists = allChannels.some(ch => ch.type === ChannelType.GuildVoice && ch.name.toLowerCase() === expected.name.toLowerCase() ); } else if (expected.type === 'forum') { exists = allChannels.some(ch => ch.type === ChannelType.GuildForum && ch.name === expected.name ); } else { exists = allChannels.some(ch => ch.type === ChannelType.GuildText && ch.name === expected.name ); } if (exists) { found.push(expected.label); } else { missing.push(expected.label); } } return { missing, found, complete: missing.length === 0, unconfigured: false }; } /** * Get server_config row from DB, or null */ async function getServerConfig(identifier) { const { rows } = await db.query( 'SELECT * FROM server_config WHERE server_identifier = $1', [identifier] ); return rows[0] || null; } // ─── PAGE ROUTES ──────────────────────────────────────────── router.get('/', (req, res) => { res.render('admin/servers/index', { title: 'Server Command Center' }); }); router.get('/matrix', async (req, res) => { const now = Date.now(); let serversData = []; if (serverCache.data && (now - serverCache.lastFetch < CACHE_TTL)) { serversData = serverCache.data; } else { const discovered = await getMinecraftServers(); for (const srv of discovered) { const props = await readServerProperties(srv.identifier); serversData.push({ ...srv, whitelistEnabled: props.whitelistEnabled, rawProps: props.raw }); } serverCache = { data: serversData, lastFetch: now }; } // Join with sync logs const { rows: logs } = await db.query('SELECT * FROM server_sync_log'); const logMap = logs.reduce((acc, log) => { acc[log.server_identifier] = log; return acc; }, {}); // Join with server_config const { rows: configs } = await db.query('SELECT * FROM server_config'); const configMap = configs.reduce((acc, cfg) => { acc[cfg.server_identifier] = cfg; return acc; }, {}); // Get Discord channels const client = req.app.locals.client; const discordChannels = await getDiscordChannels(client); const enrichedServers = serversData.map(srv => { const config = configMap[srv.identifier] || null; const shortName = config ? config.short_name : null; const channelStatus = checkServerChannels(shortName, srv.name, discordChannels); return { ...srv, config, log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' }, discord: channelStatus }; }); const txServers = enrichedServers.filter(s => s.node === 'TX1'); const ncServers = enrichedServers.filter(s => s.node === 'NC1'); res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false }); }); // ─── EXISTING ROUTES ──────────────────────────────────────── router.post('/:identifier/sync', async (req, res) => { const { identifier } = req.params; try { const { rows: players } = await db.query( `SELECT minecraft_username as name, minecraft_uuid as uuid FROM users JOIN subscriptions ON users.discord_id = subscriptions.discord_id WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')` ); await writeWhitelistFile(identifier, players); await reloadWhitelistCommand(identifier); await db.query( "INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL", [identifier] ); res.send(`✅ Synced!`); } catch (error) { await db.query( "INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false", [identifier, error.message] ); res.send(`❌ Error`); } }); router.post('/:identifier/toggle-whitelist', async (req, res) => { const { identifier } = req.params; serverCache.lastFetch = 0; const props = await readServerProperties(identifier); if (!props.exists) return res.send('File not found'); let newContent; if (props.whitelistEnabled) { newContent = props.raw.replace('white-list=true', 'white-list=false'); } else { newContent = props.raw.includes('white-list=false') ? props.raw.replace('white-list=false', 'white-list=true') : props.raw + '\nwhite-list=true'; } const endpoint = `${process.env.PANEL_URL}/api/client/servers/${identifier}/files/write?file=server.properties`; await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, 'Content-Type': 'text/plain' }, body: newContent }); res.send(`⚠️ Requires Restart`); }); router.post('/sync-all/:node', async (req, res) => { const { node } = req.params; const nodeId = node === 'tx1' ? 3 : node === 'nc1' ? 2 : null; if (!nodeId) return res.send(`Invalid node`); try { const discovered = await getMinecraftServers(); const nodeServers = discovered.filter(s => s.nodeId === nodeId); const { rows: players } = await db.query( `SELECT minecraft_username as name, minecraft_uuid as uuid FROM users JOIN subscriptions ON users.discord_id = subscriptions.discord_id WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')` ); let synced = 0, errors = 0; for (const srv of nodeServers) { try { await writeWhitelistFile(srv.identifier, players); await reloadWhitelistCommand(srv.identifier); await db.query( "INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL", [srv.identifier] ); synced++; } catch (err) { await db.query( "INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false", [srv.identifier, err.message] ); errors++; } } res.send(`✅ ${synced} synced${errors > 0 ? ` (${errors} errors)` : ''}`); } catch (error) { res.send(`❌ ${error.message}`); } }); // ─── NEW: SHORT NAME MANAGEMENT ───────────────────────────── router.post('/:identifier/set-short-name', async (req, res) => { const { identifier } = req.params; const { short_name } = req.body; // Validate: lowercase, hyphens, numbers only if (!short_name || !/^[a-z0-9-]+$/.test(short_name)) { return res.send(`❌ Invalid: lowercase, hyphens, numbers only`); } // Check if locked const config = await getServerConfig(identifier); if (config && config.short_name_locked) { return res.send(`❌ Short name is locked`); } // Check uniqueness const { rows: existing } = await db.query( 'SELECT server_identifier FROM server_config WHERE short_name = $1 AND server_identifier != $2', [short_name, identifier] ); if (existing.length > 0) { return res.send(`❌ "${short_name}" already in use`); } await db.query(` INSERT INTO server_config (server_identifier, short_name, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (server_identifier) DO UPDATE SET short_name = $2, updated_at = NOW() `, [identifier, short_name]); // Invalidate caches serverCache.lastFetch = 0; invalidateDiscordCache(); res.send(`✅ Saved: ${short_name}`); }); router.post('/:identifier/lock-short-name', async (req, res) => { const { identifier } = req.params; const config = await getServerConfig(identifier); if (!config || !config.short_name) { return res.send(`❌ Set a short name first`); } if (config.short_name_locked) { return res.send(`Already locked`); } await db.query( 'UPDATE server_config SET short_name_locked = true, updated_at = NOW() WHERE server_identifier = $1', [identifier] ); serverCache.lastFetch = 0; res.send(`🔒 Locked: ${config.short_name}`); }); // ─── NEW: CREATE/DELETE SERVER (DISCORD CHANNELS) ─────────── router.post('/:identifier/createserver', async (req, res) => { const { identifier } = req.params; const config = await getServerConfig(identifier); if (!config || !config.short_name_locked) { return res.send(`❌ Lock short name first`); } const client = req.app.locals.client; const guild = client.guilds.cache.get(process.env.GUILD_ID); if (!guild) return res.send(`❌ Guild not found`); const shortName = config.short_name; const serverName = config.pterodactyl_name || config.display_name || shortName; try { await guild.channels.fetch(); await guild.roles.fetch(); const allChannels = guild.channels.cache; // Voice channel uses display name const voiceDisplayName = serverName .split(' - ')[0] .replace(/\s*\([^)]*\)\s*/g, '') .trim(); // Define all 5 expected channels const expectedChannels = [ { name: `${shortName}-chat`, type: ChannelType.GuildText, label: 'Chat' }, { name: `${shortName}-in-game`, type: ChannelType.GuildText, label: 'In-Game' }, { name: `${shortName}-forum`, type: ChannelType.GuildForum, label: 'Forum' }, { name: `${shortName}-status`, type: ChannelType.GuildText, label: 'Status' }, { name: voiceDisplayName, type: ChannelType.GuildVoice, label: 'Voice' } ]; // Check which already exist const missing = expectedChannels.filter(exp => { return !allChannels.some(ch => ch.type === exp.type && ch.name.toLowerCase() === exp.name.toLowerCase()); }); if (missing.length === 0) { return res.send(`✅ All channels exist`); } // Find or create category let category = allChannels.find( ch => ch.type === ChannelType.GuildCategory && (ch.name === `🎮 ${serverName}` || ch.name === serverName) ); if (!category) { // Build permission overwrites const everyoneRole = guild.roles.everyone; const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer'); const serverRole = guild.roles.cache.find(r => r.name.toLowerCase() === serverName.toLowerCase()); const adminRoleIds = ADMIN_ROLES .map(name => guild.roles.cache.find(r => r.name === name)?.id) .filter(Boolean); const permissionOverwrites = [ { id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] } ]; if (wandererRole) { permissionOverwrites.push({ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] }); } if (serverRole) { permissionOverwrites.push({ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] }); } adminRoleIds.forEach(roleId => { permissionOverwrites.push({ id: roleId, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] }); }); category = await guild.channels.create({ name: `🎮 ${serverName}`, type: ChannelType.GuildCategory, permissionOverwrites, reason: `Server Command Center: createserver ${serverName}` }); } // Create only missing channels const created = []; for (const ch of missing) { const opts = { name: ch.name, type: ch.type, parent: category.id, reason: `Server Command Center: createserver ${serverName}` }; if (ch.type === ChannelType.GuildText) { opts.topic = ch.label === 'Status' ? `Uptime status for ${serverName}` : `${ch.label} for ${serverName}`; } if (ch.type === ChannelType.GuildForum) { opts.topic = `Discussion forum for ${serverName}`; opts.availableTags = STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } })); } await guild.channels.create(opts); created.push(ch.label); } // Try creating Uptime Kuma monitor (non-blocking) try { const monitorName = `${serverName} - ${config.node || 'Unknown'}`; await uptimeKuma.createMonitor(monitorName, 'localhost', 25565); } catch (kumaErr) { console.warn('[Kuma] Monitor creation skipped:', kumaErr.message); } invalidateDiscordCache(); serverCache.lastFetch = 0; res.send(`✅ Created: ${created.join(', ')}`); } catch (error) { console.error('[createserver]', error); res.send(`❌ ${error.message}`); } }); router.post('/:identifier/delserver', async (req, res) => { const { identifier } = req.params; const config = await getServerConfig(identifier); if (!config) { return res.send(`❌ No config found`); } const client = req.app.locals.client; const guild = client.guilds.cache.get(process.env.GUILD_ID); if (!guild) return res.send(`❌ Guild not found`); const serverName = config.pterodactyl_name || config.display_name || config.short_name; try { await guild.channels.fetch(); // Find category const category = guild.channels.cache.find( ch => ch.type === ChannelType.GuildCategory && (ch.name === `🎮 ${serverName}` || ch.name === serverName) ); let deletedCount = 0; if (category) { // Delete all channels in category const children = guild.channels.cache.filter(ch => ch.parentId === category.id); for (const [, channel] of children) { try { await channel.delete(`Server Command Center: delserver ${serverName}`); deletedCount++; } catch (err) { console.error(`Failed to delete channel ${channel.name}:`, err.message); } } // Delete category await category.delete(`Server Command Center: delserver ${serverName}`); deletedCount++; } // Try deleting Uptime Kuma monitor (non-blocking) try { const monitorName = `${serverName} - ${config.node || 'Unknown'}`; await uptimeKuma.deleteMonitor(monitorName); } catch (kumaErr) { console.warn('[Kuma] Monitor deletion skipped:', kumaErr.message); } invalidateDiscordCache(); serverCache.lastFetch = 0; res.send(`🗑️ Deleted ${deletedCount} channels`); } catch (error) { console.error('[delserver]', error); res.send(`❌ ${error.message}`); } }); // ─── NEW: POWER CONTROLS ──────────────────────────────────── router.post('/:identifier/power', async (req, res) => { const { identifier } = req.params; const { signal } = req.body; try { await pterodactyl.powerAction(identifier, signal); const labels = { start: '▶️ Starting', stop: '⏹ Stopping', restart: '🔄 Restarting', kill: '💀 Killing' }; res.send(`${labels[signal] || signal}...`); } catch (error) { res.send(`❌ ${error.message}`); } }); router.post('/:identifier/console', async (req, res) => { const { identifier } = req.params; const { command } = req.body; if (!command) return res.send(`❌ No command`); try { await pterodactyl.sendCommand(identifier, command); res.send(`✅ Sent`); } catch (error) { res.send(`❌ ${error.message}`); } }); // ─── NEW: SUBDOMAIN PROVISIONING ──────────────────────────── const CF_API = 'https://api.cloudflare.com/client/v4'; const CF_ZONE_ID = '7604c173d802f154035f7e998018c1a9'; router.post('/:identifier/provision-subdomain', async (req, res) => { const { identifier } = req.params; const { subdomain } = req.body; if (!subdomain || !/^[a-z0-9]+$/.test(subdomain)) { return res.send(`❌ Invalid: lowercase letters and numbers only, no hyphens`); } const config = await getServerConfig(identifier); if (!config || !config.server_ip) { return res.send(`❌ No server config or IP`); } if (config.subdomain) { return res.send(`Already provisioned: ${config.subdomain}.firefrostgaming.com`); } const cfToken = process.env.CLOUDFLARE_API_TOKEN; if (!cfToken) { return res.send(`❌ CLOUDFLARE_API_TOKEN not set`); } const fqdn = `${subdomain}.firefrostgaming.com`; try { // Create A record const aRes = await fetch(`${CF_API}/zones/${CF_ZONE_ID}/dns_records`, { method: 'POST', headers: { 'Authorization': `Bearer ${cfToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'A', name: fqdn, content: config.server_ip, ttl: 1, proxied: false }) }); const aData = await aRes.json(); if (!aData.success) { return res.send(`❌ A record: ${aData.errors?.[0]?.message || 'failed'}`); } // Create SRV record const srvRes = await fetch(`${CF_API}/zones/${CF_ZONE_ID}/dns_records`, { method: 'POST', headers: { 'Authorization': `Bearer ${cfToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'SRV', name: `_minecraft._tcp.${fqdn}`, data: { priority: 0, weight: 0, port: config.server_port || 25565, target: fqdn }, ttl: 1 }) }); const srvData = await srvRes.json(); if (!srvData.success) { console.warn('[Cloudflare] SRV failed (A record created):', srvData.errors); } // Save to DB await db.query( 'UPDATE server_config SET subdomain = $1, updated_at = NOW() WHERE server_identifier = $2', [subdomain, identifier] ); serverCache.lastFetch = 0; console.log(`[DNS] Provisioned ${fqdn} → ${config.server_ip}:${config.server_port}`); res.send(`✅ ${fqdn} provisioned`); } catch (error) { console.error('[provision-subdomain]', error); res.send(`❌ ${error.message}`); } }); module.exports = router;