- 'Homestead - A Cozy Survival Experience' now matches 'homestead-chat' - 'All The Mons (Private) - TX' now matches 'all-the-mons-chat' - Strips subtitles after ' - ' and removes parentheticals
260 lines
9.7 KiB
JavaScript
260 lines
9.7 KiB
JavaScript
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 { ChannelType } = 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
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Check which Discord channels exist for a server
|
|
* Returns object with missing channels array
|
|
*/
|
|
function checkServerChannels(serverName, allChannels) {
|
|
// Extract the base name (before any " - " subtitle or parenthetical)
|
|
// "Homestead - A Cozy Survival Experience" -> "homestead"
|
|
// "All The Mons (Private) - TX" -> "all-the-mons"
|
|
// "Stoneblock 4" -> "stoneblock-4"
|
|
let baseName = serverName
|
|
.split(' - ')[0] // Take part before " - " subtitle
|
|
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals like (Private)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
|
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
.replace(/-+/g, '-') // Multiple hyphens to single
|
|
.trim();
|
|
|
|
// Also create a display name for voice channel matching
|
|
const voiceDisplayName = serverName
|
|
.split(' - ')[0]
|
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
|
.trim();
|
|
|
|
const expectedChannels = [
|
|
{ name: `${baseName}-chat`, type: 'text', label: 'Chat' },
|
|
{ name: `${baseName}-in-game`, type: 'text', label: 'In-Game' },
|
|
{ name: `${baseName}-forum`, type: 'forum', label: 'Forum' },
|
|
{ name: voiceDisplayName, type: 'voice', label: 'Voice' }
|
|
];
|
|
|
|
const missing = [];
|
|
const found = [];
|
|
|
|
for (const expected of expectedChannels) {
|
|
let exists = false;
|
|
|
|
if (expected.type === 'voice') {
|
|
// Voice channels match by exact name (case-insensitive)
|
|
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 {
|
|
// Text channels
|
|
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 };
|
|
}
|
|
|
|
router.get('/', (req, res) => {
|
|
res.render('admin/servers/index', { title: 'Server Matrix' });
|
|
});
|
|
|
|
router.get('/matrix', async (req, res) => {
|
|
const now = Date.now();
|
|
let serversData = [];
|
|
|
|
// Use cache if valid, otherwise fetch fresh
|
|
if (serverCache.data && (now - serverCache.lastFetch < CACHE_TTL)) {
|
|
serversData = serverCache.data;
|
|
} else {
|
|
const discovered = await getMinecraftServers();
|
|
|
|
// Fetch properties for all discovered servers sequentially to avoid rate limits
|
|
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 Database Sync Logs (Always fetch fresh logs, no cache)
|
|
const { rows: logs } = await db.query('SELECT * FROM server_sync_log');
|
|
const logMap = logs.reduce((acc, log) => {
|
|
acc[log.server_identifier] = log;
|
|
return acc;
|
|
}, {});
|
|
|
|
// Get Discord channels
|
|
const client = req.app.locals.client;
|
|
const discordChannels = await getDiscordChannels(client);
|
|
|
|
const enrichedServers = serversData.map(srv => {
|
|
const channelStatus = checkServerChannels(srv.name, discordChannels);
|
|
return {
|
|
...srv,
|
|
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' },
|
|
discord: channelStatus
|
|
};
|
|
});
|
|
|
|
// Group by Node Location
|
|
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 });
|
|
});
|
|
|
|
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(`<span class="text-green-500 font-bold text-sm">✅ Synced!</span>`);
|
|
} 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(`<span class="text-red-500 font-bold text-sm">❌ Error</span>`);
|
|
}
|
|
});
|
|
|
|
router.post('/:identifier/toggle-whitelist', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
// Clear cache so the UI updates on next poll
|
|
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 {
|
|
if (props.raw.includes('white-list=false')) {
|
|
newContent = props.raw.replace('white-list=false', 'white-list=true');
|
|
} else {
|
|
newContent = 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(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
|
|
});
|
|
|
|
// Sync all servers on a specific node
|
|
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(`<span class="text-red-500">Invalid node</span>`);
|
|
}
|
|
|
|
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;
|
|
let 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(`<span class="text-green-500 font-bold">✅ ${synced} synced</span>${errors > 0 ? ` <span class="text-red-500">(${errors} errors)</span>` : ''}`);
|
|
} catch (error) {
|
|
res.send(`<span class="text-red-500">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|