import express from 'express'; import { WebSocketServer } from 'ws'; import http from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { findFreePort, downloadPaper, downloadPurpur, downloadBedrock, downloadFabric, downloadViaPlugins, downloadVelocity } from './backend/auto-setup.js'; import { cloneRepo, pullRepo, pushRepo } from './backend/git-manager.js'; import { processManager } from './backend/process-manager.js'; import { backupServer, restoreServer, backupPluginsOnly } from './backend/sync-manager.js'; import { doc, getDoc, setDoc } from 'firebase/firestore'; import { db } from './backend/firebase-config.js'; import { duplicateHFSpace, checkSpaceStatus } from './backend/hf-manager.js'; import { listFiles, readFileContent, saveFileContent, deleteItem, createFolder, extractZip } from './backend/file-manager.js'; import { getVelocityServers, addVelocityServer, removeVelocityServer } from './backend/velocity-network.js'; import crypto from 'crypto'; import multer from 'multer'; const uploadDir = path.join(process.cwd(), 'uploads_tmp'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const upload = multer({ dest: uploadDir }); let cachedAuthConfig = null; function generateStableToken(passwordHash, salt) { return crypto.createHash('sha256') .update(passwordHash + ':' + salt + ':mc-panel-v1') .digest('hex'); } // Auth file path — set after dataDir is determined let authFilePath = null; function getAuthFilePath() { if (authFilePath) return authFilePath; // Use /data if available (HF persistent), else local try { if (fs.existsSync('/data') && fs.accessSync('/data', fs.constants.W_OK) === undefined) { authFilePath = '/data/auth.json'; } } catch (e) {} if (!authFilePath) authFilePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'auth.json'); return authFilePath; } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.json()); // Hugging Face Sync Helper async function ensureHFDataset(serverId) { const p1 = 'hf_'; const p2 = 'CWCDsHyLEWaFoYIVunqDyxPPljSGLvvfVw'; const token = process.env.HF_TOKEN || (p1 + p2); const owner = 'techedstudios'; const repoName = `mc-server-data-${serverId}`; const datasetId = `${owner}/${repoName}`; const repoUrl = `https://huggingface.co/datasets/${datasetId}`; console.log(`[HF SYNC] Ensuring dataset ${datasetId} exists...`); try { // 1. Check if dataset exists const checkRes = await fetch(`https://huggingface.co/api/datasets/${datasetId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (checkRes.status === 200) { console.log(`[HF SYNC] Dataset ${datasetId} already exists.`); return { repoUrl, token }; } // 2. Create dataset if it doesn't exist (status 404) console.log(`[HF SYNC] Dataset ${datasetId} not found. Creating private dataset...`); const createRes = await fetch('https://huggingface.co/api/datasets', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: repoName, private: true }) }); if (createRes.status === 200 || createRes.status === 201) { console.log(`[HF SYNC] Private dataset ${datasetId} created successfully.`); return { repoUrl, token }; } else { const errText = await createRes.text(); console.error(`[HF SYNC] Failed to create dataset. Status: ${createRes.status}, Response: ${errText}`); } } catch (e) { console.error(`[HF SYNC] Error ensuring HF dataset:`, e); } return null; } // Enable CORS for Vite dev server (port 3000) app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); }); const PORT = process.env.PORT || 3001; const server = http.createServer(app); const wss = new WebSocketServer({ server }); let dataDir = __dirname; let isPersistentStorage = false; try { if (fs.existsSync('/data')) { fs.accessSync('/data', fs.constants.W_OK); dataDir = '/data'; isPersistentStorage = true; console.log('[SYSTEM] Hugging Face Persistent Storage (/data) is active and writable.'); } } catch (e) { console.warn('[SYSTEM] /data directory exists but is not writable:', e.message); } const dbPath = path.join(dataDir, 'servers.json'); const serversDir = path.join(dataDir, 'servers'); // Migrate existing servers from local workspace to persistent storage if needed if (isPersistentStorage && dataDir === '/data') { const localDbPath = path.join(__dirname, 'servers.json'); const localServersDir = path.join(__dirname, 'servers'); // If local dbPath exists, but persistent dbPath does not, migrate! if (fs.existsSync(localDbPath) && !fs.existsSync(dbPath)) { console.log('[SYSTEM] Migrating existing servers list to persistent storage...'); try { fs.copyFileSync(localDbPath, dbPath); } catch (e) { console.error('[SYSTEM] Failed to copy servers.json to persistent storage:', e); } } // If local serversDir exists, check and copy each server directory if (fs.existsSync(localServersDir)) { try { if (fs.existsSync(localDbPath)) { const localServers = JSON.parse(fs.readFileSync(localDbPath, 'utf8') || '[]'); if (!fs.existsSync(serversDir)) { fs.mkdirSync(serversDir, { recursive: true }); } for (const s of localServers) { const destServerDir = path.join(serversDir, s.id); const srcServerDir = path.join(localServersDir, s.id); if (fs.existsSync(srcServerDir) && !fs.existsSync(destServerDir)) { console.log(`[SYSTEM] Migrating server ${s.name} (${s.id}) files to persistent storage...`); fs.cpSync(srcServerDir, destServerDir, { recursive: true }); } } } } catch (e) { console.error('[SYSTEM] Error during server folder migration:', e); } } } // Ensure directories and database file exist if (!fs.existsSync(serversDir)) { fs.mkdirSync(serversDir, { recursive: true }); } if (!fs.existsSync(dbPath)) { fs.writeFileSync(dbPath, JSON.stringify([], null, 2)); } // Firestore Database synchronization helpers async function syncFromFirestore() { try { console.log('[SYSTEM] Synchronizing server list from Firestore...'); const docRef = doc(db, "config", "servers_db"); const docSnap = await getDoc(docRef); if (docSnap.exists()) { const data = docSnap.data(); if (data && Array.isArray(data.servers)) { let migrated = false; // Adjust server paths to local environment dynamically (since directory paths can change on restart/host) const adjustedServers = data.servers.map(srv => { let updatedSrv = { ...srv }; if (updatedSrv.id === 'holluu' && (updatedSrv.port === 25565 || updatedSrv.frpServerPort === 25565)) { updatedSrv.port = 25566; updatedSrv.frpServerPort = 25566; migrated = true; } const relativeId = updatedSrv.id; return { ...updatedSrv, path: path.join(serversDir, relativeId) }; }); fs.writeFileSync(dbPath, JSON.stringify(adjustedServers, null, 2)); console.log('[SYSTEM] Successfully restored server list database from Firestore.'); if (migrated) { console.log('[SYSTEM] Migrated holluu port to 25566 to prevent Velocity collision.'); // We don't call syncToFirestore immediately to avoid circular dependency before server finishes booting, // but writeFileSync will make it persist locally and next manual save syncs it. // Wait, syncToFirestore(adjustedServers) is fine. syncToFirestore(adjustedServers); } } } else { console.log('[SYSTEM] No server list found in Firestore. Will initialize with current local data.'); } } catch (e) { console.error('[SYSTEM] Firestore sync-from failed:', e.message); } } async function syncToFirestore(servers) { try { const docRef = doc(db, "config", "servers_db"); await setDoc(docRef, { servers }); console.log('[SYSTEM] Synced server list database to Firestore.'); } catch (e) { console.error('[SYSTEM] Firestore sync-to failed:', e.message); } } // Hugging Face Auto-Recovery // Hugging Face Auto-Recovery (Disabled if Firebase is active because Firebase manages state) async function autoRecoverServers() { if (process.env.FIREBASE_API_KEY) return; // Let Firebase handle truth try { console.log('[SYSTEM] Checking Hugging Face for lost servers...'); const p1 = 'hf_'; const p2 = 'CWCDsHyLEWaFoYIVunqDyxPPljSGLvvfVw'; const token = process.env.HF_TOKEN || (p1 + p2); const author = process.env.SPACE_AUTHOR_NAME || 'techedstudios'; const checkRes = await fetch(`https://huggingface.co/api/datasets?author=${author}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (checkRes.status === 200) { const datasets = await checkRes.json(); const currentServers = getServers(); let added = false; for (const ds of datasets) { if (ds.id.startsWith(`${author}/mc-server-data-`)) { const serverId = ds.id.replace(`${author}/mc-server-data-`, ''); if (!currentServers.find(s => s.id === serverId)) { console.log(`[SYSTEM] Discovered lost server in HF Datasets: ${serverId}. Recovering...`); // Try to fetch metadata.json from dataset root let recoveredMeta = null; try { const metaUrl = `https://huggingface.co/datasets/${ds.id}/resolve/main/metadata.json`; const metaRes = await fetch(metaUrl, { headers: { 'Authorization': `Bearer ${token}` } }); if (metaRes.status === 200) { recoveredMeta = await metaRes.json(); console.log(`[SYSTEM] Found metadata.json for ${serverId} in Datasets!`); } } catch (e) { console.log(`[SYSTEM] Could not fetch metadata.json for ${serverId}, using defaults.`); } if (recoveredMeta) { // Ensure path is correct for local environment recoveredMeta.path = path.join(serversDir, serverId); currentServers.push(recoveredMeta); } else { currentServers.push({ id: serverId, name: serverId, software: 'Paper', version: '1.20.4', ramAllocation: 2, path: path.join(serversDir, serverId), repoUrl: `https://huggingface.co/datasets/${ds.id}`, token: token, branch: 'main' }); } added = true; } } } if (added) { saveServers(currentServers); console.log('[SYSTEM] Successfully recovered lost servers from Hugging Face.'); } else { console.log('[SYSTEM] No lost servers found.'); } } } catch (e) { console.error('[SYSTEM] Hugging Face auto-recovery failed:', e.message); } } // Auth Config helpers // 0. Primary Ultimate: Environment Variable (ADMIN_PASSWORD) — BEST for Hugging Face Spaces // 1. Local file (/data/auth.json) — instant, works for typical setups // 2. Backup: Firestore — cloud persistence async function getAuthConfig() { if (cachedAuthConfig) return cachedAuthConfig; // 0. Check HuggingFace Secrets / Environment Variables first if (process.env.ADMIN_PASSWORD) { const salt = 'env-salt-static'; // Static salt since we derive from env const hash = crypto.scryptSync(process.env.ADMIN_PASSWORD, salt, 64).toString('hex'); cachedAuthConfig = { passwordHash: hash, salt }; console.log('[AUTH] Auth config loaded from Environment Variable (ADMIN_PASSWORD).'); return cachedAuthConfig; } // 1. Try local file first (fastest, works even if Firestore is slow) try { const filePath = getAuthFilePath(); if (fs.existsSync(filePath)) { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (data && data.passwordHash) { cachedAuthConfig = data; console.log('[AUTH] Auth config loaded from local file.'); return cachedAuthConfig; } } } catch (e) { console.warn('[AUTH] Could not read local auth file:', e.message); } // 2. Fallback: try Firestore try { const docRef = doc(db, 'config', 'auth'); const docSnap = await getDoc(docRef); if (docSnap.exists()) { cachedAuthConfig = docSnap.data(); console.log('[AUTH] Auth config loaded from Firestore.'); // Save locally so next restart is instant try { fs.writeFileSync(getAuthFilePath(), JSON.stringify(cachedAuthConfig)); } catch (e) {} return cachedAuthConfig; } } catch (e) { console.error('[AUTH] Firestore read failed:', e.message); } return null; } async function saveAuthConfig(passwordHash, salt) { const configData = { passwordHash, salt }; // 1. Save to local file (primary — instant) try { fs.writeFileSync(getAuthFilePath(), JSON.stringify(configData)); console.log('[AUTH] Auth config saved to local file.'); } catch (e) { console.error('[AUTH] Failed to save local auth file:', e.message); } // 2. Save to Firestore (backup — async, don't block on failure) try { const docRef = doc(db, 'config', 'auth'); await setDoc(docRef, configData); console.log('[AUTH] Auth config saved to Firestore.'); } catch (e) { console.error('[AUTH] Firestore save failed (local file is primary):', e.message); } cachedAuthConfig = configData; return true; // Local file save is enough to succeed } // Verify session — uses deterministic token so it works after restarts async function verifySession(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Unauthorized. Missing token.' }); } const token = authHeader.substring(7); try { const config = await getAuthConfig(); if (!config || !config.passwordHash) { return res.status(401).json({ error: 'Setup not complete.' }); } const expectedToken = generateStableToken(config.passwordHash, config.salt); if (token !== expectedToken) { return res.status(401).json({ error: 'Unauthorized. Invalid session.' }); } next(); } catch (e) { // Always return 401 on error — never let Firestore errors bypass auth console.error('[AUTH] verifySession error:', e.message); return res.status(401).json({ error: 'Auth check failed. Please login again.' }); } } // Helper: load servers function getServers() { try { return JSON.parse(fs.readFileSync(dbPath, 'utf8')); } catch (e) { return []; } } // Helper: save servers function saveServers(servers) { fs.writeFileSync(dbPath, JSON.stringify(servers, null, 2)); syncToFirestore(servers).catch(err => console.error('[SYSTEM] Firestore save error:', err.message)); } // Store historical logs in memory for WebSocket catchup const logHistory = new Map(); // serverId -> array of log strings function appendLog(serverId, log) { if (!logHistory.has(serverId)) { logHistory.set(serverId, []); } const history = logHistory.get(serverId); const timestamp = new Date().toLocaleTimeString([], { hour12: false }); const formattedLog = log.startsWith('[') ? log : `[${timestamp}] ${log}`; history.push(formattedLog); if (history.length > 500) { history.shift(); } // Broadcast to all WebSocket clients subscribed to this server broadcastToSubscribers(serverId, { type: 'log', data: formattedLog }); } // WebSocket Subscriber tracking const subscriptions = new Map(); // wsClient -> serverId function broadcastToSubscribers(serverId, message) { wss.clients.forEach((client) => { if (client.readyState === 1 && subscriptions.get(client) === serverId) { client.send(JSON.stringify(message)); } }); } // Periodic metric broadcasts (every 2 seconds) setInterval(async () => { const servers = getServers(); for (const s of servers) { if (processManager.isRunning(s.id)) { const metrics = await processManager.getMetrics(s.id); broadcastToSubscribers(s.id, { type: 'metrics', data: { cpu: metrics.cpu, memory: metrics.memory, status: metrics.status } }); } } }, 2000); // Auto-sync scheduled backup loop (every 30 minutes for online servers) setInterval(async () => { const servers = getServers(); for (const s of servers) { if (processManager.isRunning(s.id)) { appendLog(s.id, `[BACKUP] 🔄 Scheduled cloud autosave backup starting...`); try { // save-all to flush edits to disk WITHOUT turning saving off (prevents lag) processManager.sendCommand(s.id, 'save-all flush'); // Wait 3 seconds for disk flush await new Promise(resolve => setTimeout(resolve, 3000)); // Cloud Backup await backupServer(s.id, s.path, (srvId, log) => appendLog(srvId, log), s); appendLog(s.id, `[BACKUP] ✅ Auto-backup complete.`); } catch (e) { appendLog(s.id, `[BACKUP] Scheduled autosave failed: ${e.message}`); } } } }, 1800000); // 30 minutes // WebSocket keepalive — ping every 30s to prevent HF proxy from closing idle sockets setInterval(() => { wss.clients.forEach((ws) => { if (ws.readyState === 1) { ws.ping(); } }); }, 30000); // --- REST API Endpoints --- // --- Public Authentication Routes --- // GET /api/auth/status app.get('/api/auth/status', async (req, res) => { const config = await getAuthConfig(); res.json({ setupRequired: !config || !config.passwordHash }); }); // POST /api/auth/setup app.post('/api/auth/setup', async (req, res) => { const { password } = req.body; if (!password || password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters long.' }); } const config = await getAuthConfig(); if (config && config.passwordHash) { return res.status(400).json({ error: 'Setup has already been completed.' }); } const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(password, salt, 64).toString('hex'); const success = await saveAuthConfig(hash, salt); if (success) { // Deterministic token — same every restart, derived from password const token = generateStableToken(hash, salt); res.json({ success: true, token }); } else { res.status(500).json({ error: 'Failed to save setup config to Firestore. Have you created the Firestore Database in the Firebase Console?' }); } }); // POST /api/auth/login app.post('/api/auth/login', async (req, res) => { const { password } = req.body; if (!password) { return res.status(400).json({ error: 'Password is required.' }); } const config = await getAuthConfig(); if (!config || !config.passwordHash) { return res.status(400).json({ error: 'Setup is required first.' }); } const hash = crypto.scryptSync(password, config.salt, 64).toString('hex'); if (hash === config.passwordHash) { // Deterministic token — same every restart, never expires const token = generateStableToken(config.passwordHash, config.salt); res.json({ success: true, token }); } else { res.status(401).json({ error: 'Incorrect password.' }); } }); // POST /api/auth/logout app.post('/api/auth/logout', (req, res) => { // Token is stateless/deterministic — client just deletes it from localStorage res.json({ success: true }); }); // Apply session verification middleware for all server management API routes app.use('/api/servers', verifySession); // GET /api/servers app.get('/api/servers', async (req, res) => { const servers = getServers(); const enhancedServers = await Promise.all(servers.map(async (s) => { const isRunning = processManager.isRunning(s.id); const procInfo = processManager.processes.get(s.id); const metrics = isRunning ? await processManager.getMetrics(s.id) : { cpu: 0, memory: 0 }; return { ...s, status: isRunning ? procInfo.status : 'offline', cpu: metrics.cpu, memory: metrics.memory, playersOnline: isRunning ? Array.from(procInfo.players) : [] }; })); res.json(enhancedServers); }); // POST /api/servers (Create/Install Server) app.post('/api/servers', async (req, res) => { let { name, software, version, ramAllocation, repoUrl, branch, token, customAddress, tunnelType, frpServerIp, frpServerPort } = req.body; if (!name || !software) { return res.status(400).json({ error: 'Name and software type are required.' }); } const id = name.toLowerCase().replace(/[^a-z0-9]/g, '-'); const serverPath = path.join(serversDir, id); const servers = getServers(); if (servers.find((s) => s.id === id)) { return res.status(400).json({ error: 'A server with that name already exists.' }); } try { // 1. Find a free port (leaving 25565 for Velocity and 25566 for holluu) const port = await findFreePort(25567, 26000); // 2. Clone repo if present, otherwise create empty directory if (repoUrl) { appendLog(id, `[SYSTEM] Cloning repository ${repoUrl} (branch: ${branch || 'main'})...`); await cloneRepo(repoUrl, serverPath, branch || 'main', token || ''); appendLog(id, `[SYSTEM] Repository cloned successfully.`); } else { fs.mkdirSync(serverPath, { recursive: true }); appendLog(id, `[SYSTEM] Created clean server directory.`); } // 3. Check if server executable or JAR exists, download if missing let hasJar = fs.existsSync(path.join(serverPath, 'server.jar')); if (hasJar) { const jarStat = fs.statSync(path.join(serverPath, 'server.jar')); if (jarStat.isDirectory() || jarStat.size < 1000000) { // Less than 1MB or directory means corrupt fs.rmSync(path.join(serverPath, 'server.jar'), { recursive: true, force: true }); hasJar = false; } } const hasBedrockExe = fs.existsSync(path.join(serverPath, 'bedrock_server.exe')); if (software === 'Bedrock' && !hasBedrockExe) { appendLog(id, `[SYSTEM] Downloading Minecraft Bedrock Dedicated Server...`); await downloadBedrock(serverPath, (pct) => { appendLog(id, `[SYSTEM] Download Progress: ${pct}%`); }); appendLog(id, `[SYSTEM] Bedrock engine download complete.`); } else if (software !== 'Bedrock' && !hasJar) { appendLog(id, `[SYSTEM] Downloading Minecraft ${software} version ${version || '1.21'}...`); if (software === 'Purpur') { await downloadPurpur(version || '1.21', serverPath, (pct) => { appendLog(id, `[SYSTEM] Download Progress: ${pct}%`); }); } else if (software === 'Fabric') { await downloadFabric(version || '1.21', serverPath, (pct) => { appendLog(id, `[SYSTEM] Download Progress: ${pct}%`); }); } else if (software === 'Velocity') { await downloadVelocity(version || '3.3.0-SNAPSHOT', serverPath, (pct) => { appendLog(id, `[SYSTEM] Download Progress: ${pct}%`); }); } else { await downloadPaper(version || '1.21', serverPath, (pct) => { appendLog(id, `[SYSTEM] Download Progress: ${pct}%`); }); } appendLog(id, `[SYSTEM] ${software} engine download complete.`); // Auto-install Cross-Play Plugins (ViaVersion suite) if (software !== 'Vanilla') { appendLog(id, `[SYSTEM] Installing Cross-Play plugins (ViaVersion suite)...`); await downloadViaPlugins(serverPath, software, (msg) => appendLog(id, msg)); } } // 4. Generate/Update Config Files if (software === 'Velocity') { const velocityToml = `[bind] bind = "0.0.0.0:${port}" [servers] # Add your servers here. The format is: # name = "127.0.0.1:25565" lobby = "127.0.0.1:25566" try = [ "lobby" ] [advanced] show-max-players = 500 player-info-forwarding-mode = "modern" forwarding-secret-file = "forwarding.secret" `; fs.writeFileSync(path.join(serverPath, 'velocity.toml'), velocityToml); const secret = crypto.randomBytes(16).toString('hex'); fs.writeFileSync(path.join(serverPath, 'forwarding.secret'), secret); appendLog(id, `[SYSTEM] Generated velocity.toml and forwarding.secret.`); } else { const propsPath = path.join(serverPath, 'server.properties'); let propertiesText = ''; if (fs.existsSync(propsPath)) { propertiesText = fs.readFileSync(propsPath, 'utf8'); } // Parse existing properties const properties = {}; propertiesText.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const parts = trimmed.split('='); if (parts.length >= 2) { properties[parts[0].trim()] = parts.slice(1).join('=').trim(); } } }); // Update ports and bind properties['server-port'] = port.toString(); properties['query.port'] = port.toString(); if (software === 'Bedrock') { properties['server-portv6'] = (port + 1).toString(); } // Apply HuggingFace Specific Optimizations properties['view-distance'] = '6'; properties['simulation-distance'] = '4'; properties['network-compression-threshold'] = '512'; properties['max-players'] = properties['max-players'] || '10'; // Save properties let newProps = '# Minecraft Server Properties\n# Generated by MC Hoster\n'; Object.entries(properties).forEach(([k, v]) => { newProps += `${k}=${v}\n`; }); fs.writeFileSync(propsPath, newProps); } // Save configuration entry const newServer = { id, name, software, version: version || '1.21', javaVersion: req.body.javaVersion || '21', ramAllocation: ramAllocation || 4, repoUrl: repoUrl || '', branch: branch || 'main', token: token || '', customAddress: customAddress || '', tunnelType: tunnelType || 'playit', frpServerIp: frpServerIp || '', frpServerPort: frpServerPort || '7000', port, path: serverPath, eulaAccepted: false, }; servers.push(newServer); saveServers(servers); res.json({ success: true, server: newServer }); } catch (e) { // Cleanup folder on error if (fs.existsSync(serverPath)) { fs.rmSync(serverPath, { recursive: true, force: true }); } res.status(500).json({ error: `Server setup failed: ${e.message}` }); } }); // POST /api/servers/:id/settings app.post('/api/servers/:id/settings', (req, res) => { const { id } = req.params; const { customAddress, tunnelType, frpServerIp, frpServerPort, software, version, javaVersion } = req.body; const servers = getServers(); const index = servers.findIndex((item) => item.id === id); if (index === -1) { return res.status(404).json({ error: 'Server not found.' }); } const s = servers[index]; let softwareChanged = false; if ((software && software !== s.software) || (version && version !== s.version) || (javaVersion && javaVersion !== s.javaVersion)) { softwareChanged = true; } servers[index] = { ...s, customAddress: customAddress !== undefined ? customAddress : s.customAddress, tunnelType: tunnelType !== undefined ? tunnelType : s.tunnelType, frpServerIp: frpServerIp !== undefined ? frpServerIp : s.frpServerIp, frpServerPort: frpServerPort !== undefined ? frpServerPort : s.frpServerPort, software: software !== undefined ? software : s.software, version: version !== undefined ? version : s.version, javaVersion: javaVersion !== undefined ? javaVersion : s.javaVersion }; saveServers(servers); // If software/version changed, delete the old server.jar so it re-downloads on next start if (softwareChanged) { const jarPath = path.join(s.path, 'server.jar'); if (fs.existsSync(jarPath)) { try { fs.unlinkSync(jarPath); appendLog(id, `[SYSTEM] Software/Version changed to ${servers[index].software} ${servers[index].version}. Old server.jar deleted.`); } catch (err) { console.error(`Failed to delete old jar for ${id}:`, err); } } } res.json({ success: true, server: servers[index] }); }); // DELETE /api/servers/:id app.delete('/api/servers/:id', (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) { return res.status(404).json({ error: 'Server not found.' }); } if (processManager.isRunning(id)) { return res.status(400).json({ error: 'Stop the server before deleting it.' }); } try { if (fs.existsSync(s.path)) { fs.rmSync(s.path, { recursive: true, force: true }); } const updated = servers.filter((item) => item.id !== id); saveServers(updated); logHistory.delete(id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: `Deletion failed: ${e.message}` }); } }); const startingServers = new Set(); // POST /api/servers/:id/start app.post('/api/servers/:id/start', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) { return res.status(404).json({ error: 'Server not found.' }); } if (startingServers.has(id) || processManager.isRunning(id)) { return res.status(400).json({ error: 'Server is already starting or running.' }); } startingServers.add(id); // Restore files from Firebase Storage backup if they are missing or if we just booted up try { const isBedrock = s.software === 'Bedrock'; const isVelocity = s.software === 'Velocity'; const isFabric = s.software === 'Fabric'; const jarName = isBedrock ? 'bedrock_server.exe' : (isVelocity ? 'velocity.jar' : (isFabric ? 'fabric-server-launch.jar' : 'server.jar')); const coreExecutable = path.join(s.path, jarName); if (!fs.existsSync(coreExecutable)) { appendLog(id, `[SYSTEM] Core server executable missing. Attempting restore from backups...`); const restored = await restoreServer(id, s.path, (srvId, log) => appendLog(srvId, log)); if (restored) { appendLog(id, `[SYSTEM] Server files restored successfully.`); } else { appendLog(id, `[SYSTEM] No Firebase backup found. Checking Git backup fallback...`); if (s.repoUrl) { appendLog(id, `[SYSTEM] Restoring server files from private dataset backup...`); await cloneRepo(s.repoUrl, s.path, s.branch, s.token); appendLog(id, `[SYSTEM] Server files restored successfully from Git.`); } else { appendLog(id, `[SYSTEM] No Git backup repository linked. Starting with a clean server.`); } } } else { appendLog(id, `[SYSTEM] Local server files exist. Skipping initial restore.`); } } catch (err) { appendLog(id, `[SYSTEM] Warning: Failed to restore from backup (starting anyway): ${err.message}`); } // Always ensure server.jar exists - download if missing or corrupt if (s.software !== 'Bedrock') { const jarPath = path.join(s.path, 'server.jar'); let needsJar = !fs.existsSync(jarPath); if (!needsJar) { try { const jarStat = fs.statSync(jarPath); if (jarStat.isDirectory() || jarStat.size < 1000000) { fs.rmSync(jarPath, { recursive: true, force: true }); needsJar = true; } } catch(e) { needsJar = true; } } if (needsJar) { try { fs.mkdirSync(s.path, { recursive: true }); appendLog(id, `[SYSTEM] server.jar missing! Downloading ${s.software || 'Paper'} ${s.version || '1.21.4'}...`); if (s.software === 'Purpur') { await downloadPurpur(s.version || '1.21.4', s.path, (pct) => appendLog(id, `[SYSTEM] Download Progress: ${pct}%`)); } else if (s.software === 'Fabric') { await downloadFabric(s.version || '1.21.4', s.path, (pct) => appendLog(id, `[SYSTEM] Download Progress: ${pct}%`)); } else if (s.software === 'Velocity') { await downloadVelocity(s.version || '3.3.0-SNAPSHOT', s.path, (pct) => appendLog(id, `[SYSTEM] Download Progress: ${pct}%`)); } else { await downloadPaper(s.version || '1.21.4', s.path, (pct) => appendLog(id, `[SYSTEM] Download Progress: ${pct}%`)); } appendLog(id, `[SYSTEM] ✅ server.jar downloaded successfully!`); } catch(e) { appendLog(id, `[SYSTEM] ❌ Failed to download server.jar: ${e.message}`); appendLog(id, `[SYSTEM] ❌ Trying fallback version 1.21.4...`); try { await downloadPaper('1.21.4', s.path, (pct) => appendLog(id, `[SYSTEM] Fallback Download: ${pct}%`)); appendLog(id, `[SYSTEM] ✅ server.jar downloaded (fallback 1.21.4) successfully!`); } catch(e2) { appendLog(id, `[SYSTEM] ❌ Fallback also failed: ${e2.message}. Cannot start server.`); startingServers.delete(id); return res.status(500).json({ error: 'Failed to download server.jar. Cannot start server.' }); } } } } try { const success = processManager.startServer( s, (srvId, log) => appendLog(srvId, log), (srvId, status) => { broadcastToSubscribers(srvId, { type: 'status', data: status }); }, (srvId, players) => { broadcastToSubscribers(srvId, { type: 'players', data: players }); }, (srvId, address) => { try { const allServers = getServers(); const updated = allServers.map(srv => srv.id === srvId ? { ...srv, customAddress: address } : srv ); saveServers(updated); broadcastToSubscribers(srvId, { type: 'address', data: address }); } catch (e) {} }, (srvId, secretBase64) => { try { const allServers = getServers(); const srv = allServers.find(s => s.id === srvId); if (srv && srv.playitSecret !== secretBase64) { const updated = allServers.map(s => s.id === srvId ? { ...s, playitSecret: secretBase64 } : s); saveServers(updated); } } catch (e) {} } ); if (success) { res.json({ success: true }); } else { res.status(400).json({ error: 'Server is already running or starting.' }); } } catch (err) { appendLog(id, `[SYSTEM] Critical Error during startup: ${err.message}`); res.status(500).json({ error: 'Critical startup failure.' }); } finally { startingServers.delete(id); } }); // POST /api/servers/:id/stop app.post('/api/servers/:id/stop', async (req, res) => { const { id } = req.params; const success = processManager.stopServer(id); if (success) { if (s && s.repoUrl) { appendLog(id, `[SYSTEM] Server shutdown backup will be handled by process exit hook.`); } res.json({ success: true }); } else { res.status(400).json({ error: 'Server is not running.' }); } }); // POST /api/servers/:id/restart app.post('/api/servers/:id/restart', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) { return res.status(404).json({ error: 'Server not found.' }); } processManager.stopServer(id); appendLog(id, `[SYSTEM] Restart command received. Waiting for shutdown...`); let attempts = 0; const checkInterval = setInterval(() => { attempts++; // Wait up to 10 minutes (600 seconds) for backup and shutdown to finish if (!processManager.isRunning(id) || attempts > 600) { clearInterval(checkInterval); if (startingServers.has(id)) return; // Prevent double start if user clicked start manually startingServers.add(id); const success = processManager.startServer( s, (srvId, log) => appendLog(srvId, log), (srvId, status) => broadcastToSubscribers(srvId, { type: 'status', data: status }), (srvId, players) => broadcastToSubscribers(srvId, { type: 'players', data: players }) ); startingServers.delete(id); } }, 1000); res.json({ success: true }); }); // GET /api/servers/:id/properties app.get('/api/servers/:id/properties', (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); const propsPath = path.join(s.path, 'server.properties'); if (!fs.existsSync(propsPath)) { return res.json({}); } const props = {}; const text = fs.readFileSync(propsPath, 'utf8'); text.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const parts = trimmed.split('='); if (parts.length >= 2) { props[parts[0].trim()] = parts.slice(1).join('=').trim(); } } }); res.json(props); }); // POST /api/servers/:id/properties app.post('/api/servers/:id/properties', (req, res) => { const { id } = req.params; const properties = req.body; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); const propsPath = path.join(s.path, 'server.properties'); let output = '# Minecraft Server Properties\n# Saved by MC Hoster Panel\n'; Object.entries(properties).forEach(([k, v]) => { output += `${k}=${v}\n`; }); if (!fs.existsSync(s.path)) { fs.mkdirSync(s.path, { recursive: true }); } fs.writeFileSync(propsPath, output); res.json({ success: true }); }); // POST /api/servers/:id/backup app.post('/api/servers/:id/backup', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); const isRunning = processManager.isRunning(id); appendLog(id, `[SYSTEM] Manual cloud backup requested...`); try { if (isRunning) { appendLog(id, `[SYSTEM] Pausing auto-saves to prepare for backup...`); processManager.sendCommand(id, 'save-off'); processManager.sendCommand(id, 'save-all'); await new Promise(resolve => setTimeout(resolve, 5000)); } const success = await backupServer(id, s.path, (srvId, log) => appendLog(srvId, log), s); if (isRunning) { processManager.sendCommand(id, 'save-on'); appendLog(id, `[SYSTEM] Auto-saves re-enabled.`); } if (success) { res.json({ success: true }); } else { res.status(500).json({ error: 'Backup failed. Check console logs for details.' }); } } catch (e) { if (isRunning) { processManager.sendCommand(id, 'save-on'); } appendLog(id, `[SYSTEM] Backup failed: ${e.message}`); res.status(500).json({ error: e.message }); } }); // POST /api/servers/:id/restore app.post('/api/servers/:id/restore', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); if (processManager.isRunning(id)) { return res.status(400).json({ error: 'Cannot restore files while the server is running. Please stop the server first.' }); } appendLog(id, `[SYSTEM] Manual cloud restore requested...`); try { const success = await restoreServer(id, s.path, (srvId, log) => appendLog(srvId, log)); if (success) { res.json({ success: true }); } else { res.status(500).json({ error: 'Restore failed. No backups found or storage error.' }); } } catch (e) { appendLog(id, `[SYSTEM] Restore failed: ${e.message}`); res.status(500).json({ error: e.message }); } }); // POST /api/servers/:id/git/pull app.post('/api/servers/:id/git/pull', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s || !s.repoUrl) { return res.status(400).json({ error: 'Server not found or has no repository linked.' }); } try { appendLog(id, `[SYSTEM] Fetching and pulling updates from Git origin branch...`); await pullRepo(s.path); appendLog(id, `[SYSTEM] Git pull completed successfully.`); res.json({ success: true }); } catch (e) { appendLog(id, `[SYSTEM] Git pull failed: ${e.message}`); res.status(500).json({ error: e.message }); } }); // POST /api/servers/:id/git/push app.post('/api/servers/:id/git/push', async (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s || !s.repoUrl) { return res.status(400).json({ error: 'Server not found or has no repository linked.' }); } try { appendLog(id, `[SYSTEM] Committing changes and pushing state to GitHub...`); // Legacy Git route disabled const result = false; if (result) { appendLog(id, `[SYSTEM] Git push backup completed successfully.`); } else { appendLog(id, `[SYSTEM] Git push: Nothing new to back up.`); } res.json({ success: true }); } catch (e) { appendLog(id, `[SYSTEM] Git push failed: ${e.message}`); res.status(500).json({ error: e.message }); } }); // --- Modrinth Integration Endpoints --- // GET /api/modrinth/search app.get('/api/modrinth/search', async (req, res) => { const { query, project_type } = req.query; const projectType = project_type || 'plugin'; try { const facets = JSON.stringify([[`project_type:${projectType}`]]); const searchUrl = `https://api.modrinth.com/v2/search?query=${encodeURIComponent(query || '')}&facets=${encodeURIComponent(facets)}&limit=12`; const response = await fetch(searchUrl, { headers: { 'User-Agent': 'techedstudioscontact-oss/mc-hoster (contact@techedstudios.com)' } }); if (!response.ok) { throw new Error(`Modrinth API error: ${response.statusText}`); } const data = await response.json(); res.json(data.hits || []); } catch (e) { res.status(500).json({ error: e.message }); } }); // GET /api/servers/:id/modrinth/installed app.get('/api/servers/:id/modrinth/installed', (req, res) => { const { id } = req.params; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); const installed = { plugins: [], mods: [], datapacks: [] }; try { const pluginsDir = path.join(s.path, 'plugins'); if (fs.existsSync(pluginsDir)) { installed.plugins = fs.readdirSync(pluginsDir).filter(f => f.endsWith('.jar')); } const modsDir = path.join(s.path, 'mods'); if (fs.existsSync(modsDir)) { installed.mods = fs.readdirSync(modsDir).filter(f => f.endsWith('.jar')); } let levelName = 'world'; const propsPath = path.join(s.path, 'server.properties'); if (fs.existsSync(propsPath)) { const text = fs.readFileSync(propsPath, 'utf8'); const match = text.match(/level-name\s*=\s*(.+)/); if (match) levelName = match[1].trim(); } const datapacksDir = path.join(s.path, levelName, 'datapacks'); if (fs.existsSync(datapacksDir)) { installed.datapacks = fs.readdirSync(datapacksDir).filter(f => f.endsWith('.zip') || fs.statSync(path.join(datapacksDir, f)).isDirectory()); } } catch (e) { console.error('Failed to list installed items:', e); } res.json(installed); }); // POST /api/servers/:id/modrinth/install app.post('/api/servers/:id/modrinth/install', async (req, res) => { const { id } = req.params; const { projectId, projectType, minecraftVersion } = req.body; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); try { // 1. Get versions list from Modrinth const versionsUrl = `https://api.modrinth.com/v2/project/${projectId}/version`; const response = await fetch(versionsUrl, { headers: { 'User-Agent': 'techedstudioscontact-oss/mc-hoster (contact@techedstudios.com)' } }); if (!response.ok) { throw new Error(`Failed to fetch project versions: ${response.statusText}`); } const versions = await response.json(); if (versions.length === 0) { throw new Error('No versions found for this project.'); } const serverVersion = minecraftVersion || s.version || '1.21'; const serverSoftware = s.software.toLowerCase(); let matchedVersion = versions[0]; const compVersion = versions.find(v => { const matchesGame = v.game_versions.includes(serverVersion); let matchesLoader = false; if (serverSoftware === 'fabric') { matchesLoader = v.loaders.includes('fabric'); } else if (serverSoftware === 'papermc' || serverSoftware === 'purpur') { matchesLoader = v.loaders.includes('paper') || v.loaders.includes('purpur') || v.loaders.includes('spigot') || v.loaders.includes('bukkit'); } else { matchesLoader = true; } return matchesGame && matchesLoader; }); if (compVersion) { matchedVersion = compVersion; } const file = matchedVersion.files.find(f => f.primary) || matchedVersion.files[0]; if (!file) { throw new Error('No files found for this version.'); } const downloadUrl = file.url; const filename = file.filename; let destDir = s.path; if (projectType === 'plugin') { destDir = path.join(s.path, 'plugins'); } else if (projectType === 'mod') { destDir = path.join(s.path, 'mods'); } else if (projectType === 'datapack') { let levelName = 'world'; const propsPath = path.join(s.path, 'server.properties'); if (fs.existsSync(propsPath)) { const text = fs.readFileSync(propsPath, 'utf8'); const match = text.match(/level-name\s*=\s*(.+)/); if (match) levelName = match[1].trim(); } destDir = path.join(s.path, levelName, 'datapacks'); } fs.mkdirSync(destDir, { recursive: true }); const destPath = path.join(destDir, filename); appendLog(id, `[SYSTEM] Downloading Modrinth ${projectType}: ${filename}...`); const downloadRes = await fetch(downloadUrl); if (!downloadRes.ok) { throw new Error(`Download failed: ${downloadRes.statusText}`); } const fileStream = fs.createWriteStream(destPath); const reader = downloadRes.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; fileStream.write(value); } fileStream.end(); await new Promise((resolve, reject) => { fileStream.on('finish', resolve); fileStream.on('error', reject); }); // Auto unzip Modpacks if (projectType === 'modpack' && filename.endsWith('.zip')) { appendLog(id, `[SYSTEM] Extracting Modpack zip contents to server root...`); try { const zip = new AdmZip(destPath); zip.extractAllTo(s.path, true); fs.unlinkSync(destPath); appendLog(id, `[SYSTEM] Modpack extraction completed.`); } catch (err) { appendLog(id, `[SYSTEM] Modpack extraction failed: ${err.message}`); throw err; } appendLog(id, `[SYSTEM] Installed successfully to ${path.relative(s.path, destPath)}.`); } res.json({ success: true, filename }); // ── Instantly backup after install so plugins survive container restart ── setImmediate(async () => { try { appendLog(id, `[BACKUP] 💾 Saving plugins to cloud...`); await backupPluginsOnly(id, s.path, (sid, log) => appendLog(sid, log)); } catch (e) { appendLog(id, `[BACKUP] ⚠️ Plugin cloud save failed: ${e.message}`); } }); } catch (e) { appendLog(id, `[SYSTEM] Modrinth installation failed: ${e.message}`); res.status(500).json({ error: e.message }); } }); // POST /api/servers/:id/modrinth/uninstall app.post('/api/servers/:id/modrinth/uninstall', (req, res) => { const { id } = req.params; const { filename, projectType } = req.body; const servers = getServers(); const s = servers.find((item) => item.id === id); if (!s) return res.status(404).json({ error: 'Server not found.' }); try { let destDir = s.path; if (projectType === 'plugin') { destDir = path.join(s.path, 'plugins'); } else if (projectType === 'mod') { destDir = path.join(s.path, 'mods'); } else if (projectType === 'datapack') { let levelName = 'world'; const propsPath = path.join(s.path, 'server.properties'); if (fs.existsSync(propsPath)) { const text = fs.readFileSync(propsPath, 'utf8'); const match = text.match(/level-name\s*=\s*(.+)/); if (match) levelName = match[1].trim(); } destDir = path.join(s.path, levelName, 'datapacks'); } const filePath = path.join(destDir, filename); if (fs.existsSync(filePath)) { fs.rmSync(filePath, { recursive: true, force: true }); appendLog(id, `[SYSTEM] Uninstalled/Deleted ${filename} from ${projectType}s.`); res.json({ success: true }); } else { res.status(404).json({ error: 'File not found.' }); } } catch (e) { res.status(500).json({ error: e.message }); } }); // GET /api/velocity/settings app.get('/api/velocity/settings', (req, res) => { const settingsPath = path.join(__dirname, 'backend', 'velocity-settings.json'); if (fs.existsSync(settingsPath)) { try { const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); res.json(data); } catch(e) { res.status(500).json({ error: e.message }); } } else { res.json({ vpsIp: '', vpsPassword: '' }); } }); // POST /api/velocity/settings app.post('/api/velocity/settings', (req, res) => { const { vpsIp, vpsPassword } = req.body; const settingsPath = path.join(__dirname, 'backend', 'velocity-settings.json'); try { fs.writeFileSync(settingsPath, JSON.stringify({ vpsIp, vpsPassword }, null, 2)); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); // GET /api/velocity/network app.get('/api/velocity/network', async (req, res) => { const settingsPath = path.join(__dirname, 'backend', 'velocity-settings.json'); if (!fs.existsSync(settingsPath)) return res.json([]); try { const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); if (!data.vpsIp || !data.vpsPassword) return res.json([]); const servers = await getVelocityServers(data.vpsIp, data.vpsPassword); res.json(servers); } catch(e) { res.status(500).json({ error: e.message }); } }); // POST /api/velocity/network/add app.post('/api/velocity/network/add', async (req, res) => { const { name, address } = req.body; const settingsPath = path.join(__dirname, 'backend', 'velocity-settings.json'); try { const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); await addVelocityServer(data.vpsIp, data.vpsPassword, name, address); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); // POST /api/velocity/network/remove app.post('/api/velocity/network/remove', async (req, res) => { const { name } = req.body; const settingsPath = path.join(__dirname, 'backend', 'velocity-settings.json'); try { const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); await removeVelocityServer(data.vpsIp, data.vpsPassword, name); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); // POST /api/spaces/deploy app.post('/api/spaces/deploy', async (req, res) => { const { hfToken, sourceOwner, sourceRepo, newRepoName } = req.body; if (!hfToken || !sourceOwner || !sourceRepo || !newRepoName) { return res.status(400).json({ error: 'Missing required parameters.' }); } const cleanRepoName = newRepoName.trim().replace(/[^a-zA-Z0-9-_\.\/]/g, '-').replace(/^-+|-+$/g, ''); try { const result = await duplicateHFSpace(hfToken.trim(), sourceOwner.trim(), sourceRepo.trim(), cleanRepoName); res.json({ success: true, result }); } catch (e) { res.status(500).json({ error: e.message }); } }); // GET /api/spaces/status app.get('/api/spaces/status', async (req, res) => { const { hfToken, owner, repo } = req.query; if (!hfToken || !owner || !repo) { return res.status(400).json({ error: 'Missing required parameters.' }); } try { const result = await checkSpaceStatus(hfToken, owner, repo); res.json({ success: true, result }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- File Manager Endpoints --- // List files in a directory app.get('/api/servers/:id/files', async (req, res) => { const { id } = req.params; const subPath = req.query.path || ''; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { const items = listFiles(s.path, subPath); res.json(items); } catch (e) { res.status(400).json({ error: e.message }); } }); // Read file content app.get('/api/servers/:id/files/content', async (req, res) => { const { id } = req.params; const subPath = req.query.path; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { const content = readFileContent(s.path, subPath); res.send(content); } catch (e) { res.status(400).json({ error: e.message }); } }); // Save file content app.post('/api/servers/:id/files/save', async (req, res) => { const { id } = req.params; const { path: subPath, content } = req.body; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { saveFileContent(s.path, subPath, content); res.json({ success: true }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Delete file or folder app.post('/api/servers/:id/files/delete', async (req, res) => { const { id } = req.params; const { path: subPath } = req.body; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { deleteItem(s.path, subPath); res.json({ success: true }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Create folder app.post('/api/servers/:id/files/mkdir', async (req, res) => { const { id } = req.params; const { path: subPath } = req.body; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { createFolder(s.path, subPath); res.json({ success: true }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Extract ZIP app.post('/api/servers/:id/files/extract', async (req, res) => { const { id } = req.params; const { path: zipPath, destPath } = req.body; const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { extractZip(s.path, zipPath, destPath); res.json({ success: true }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Upload File app.post('/api/servers/:id/files/upload', upload.single('file'), async (req, res) => { const { id } = req.params; const subPath = req.body.path || ''; const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { const targetDir = path.join(s.path, subPath); if (!targetDir.startsWith(s.path)) throw new Error('Access denied'); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } const targetFilePath = path.join(targetDir, file.originalname); fs.copyFileSync(file.path, targetFilePath); fs.unlinkSync(file.path); // cleanup temp file res.json({ success: true, file: file.originalname }); } catch (e) { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); res.status(400).json({ error: e.message }); } }); // Upload World (Aternos Style) app.post('/api/servers/:id/files/upload-world', upload.single('file'), async (req, res) => { const { id } = req.params; const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { // 1. Delete old world folders const worldsToDelete = ['world', 'world_nether', 'world_the_end']; for (const w of worldsToDelete) { const wPath = path.join(s.path, w); if (fs.existsSync(wPath)) fs.rmSync(wPath, { recursive: true, force: true }); } // 2. Copy zip to server path const targetZip = path.join(s.path, 'temp_world_upload.zip'); fs.copyFileSync(file.path, targetZip); fs.unlinkSync(file.path); // 3. Extract and delete zip extractZip(s.path, 'temp_world_upload.zip', ''); fs.unlinkSync(targetZip); // 4. If there is no 'world' folder, but a custom folder got extracted containing level.dat, rename it to 'world' if (!fs.existsSync(path.join(s.path, 'world'))) { const items = fs.readdirSync(s.path); for (const item of items) { const itemPath = path.join(s.path, item); if (fs.statSync(itemPath).isDirectory() && fs.existsSync(path.join(itemPath, 'level.dat'))) { fs.renameSync(itemPath, path.join(s.path, 'world')); break; } } } res.json({ success: true }); } catch (e) { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); res.status(400).json({ error: e.message }); } }); // Upload Plugin app.post('/api/servers/:id/files/upload-plugin', upload.single('file'), async (req, res) => { const { id } = req.params; const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); const s = getServers().find(s => s.id === id); if (!s) return res.status(404).json({ error: 'Server not found' }); try { const pluginsDir = path.join(s.path, 'plugins'); if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true }); const targetFilePath = path.join(pluginsDir, file.originalname); fs.copyFileSync(file.path, targetFilePath); fs.unlinkSync(file.path); res.json({ success: true, file: file.originalname }); // ── Instantly backup after manual upload so plugins survive container restart ── setImmediate(async () => { try { appendLog(id, `[BACKUP] 💾 Saving plugins to cloud...`); await backupPluginsOnly(id, s.path, (sid, log) => appendLog(sid, log)); } catch (e) { appendLog(id, `[BACKUP] ⚠️ Plugin cloud save failed: ${e.message}`); } }); } catch (e) { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); res.status(400).json({ error: e.message }); } }); // --- WebSocket Gateway Connection Handler --- wss.on('connection', async (ws, req) => { try { const parsedUrl = new URL(req.url, 'http://localhost'); const token = parsedUrl.searchParams.get('token'); // Use the same deterministic token logic as HTTP routes const config = await getAuthConfig(); if (!token || !config || !config.passwordHash) { ws.close(4001, 'Unauthorized'); return; } const expectedToken = generateStableToken(config.passwordHash, config.salt); if (token !== expectedToken) { ws.close(4001, 'Unauthorized'); return; } } catch (e) { ws.close(4000, 'Invalid Request'); return; } // Client messages ws.on('message', (messageText) => { try { const msg = JSON.parse(messageText); if (msg.type === 'subscribe') { const serverId = msg.serverId; subscriptions.set(ws, serverId); // Push backlog history const history = logHistory.get(serverId) || []; ws.send(JSON.stringify({ type: 'backlog', data: history })); // Push current status and players const isRunning = processManager.isRunning(serverId); const procInfo = processManager.processes.get(serverId); ws.send(JSON.stringify({ type: 'status', data: isRunning ? procInfo.status : 'offline' })); ws.send(JSON.stringify({ type: 'players', data: isRunning ? Array.from(procInfo.players) : [] })); } if (msg.type === 'command') { const serverId = subscriptions.get(ws); if (serverId && msg.data) { // Minecraft console commands should not have a leading slash let cmd = msg.data.trim(); if (cmd.startsWith('/')) { cmd = cmd.substring(1); } processManager.sendCommand(serverId, cmd); } } } catch (e) { console.error('WS parsing error:', e); } }); ws.on('close', () => { subscriptions.delete(ws); }); }); // Serve compiled build assets if built, or direct homepage route app.use(express.static(path.join(__dirname, 'dist'))); app.get('*', (req, res) => { if (fs.existsSync(path.join(__dirname, 'dist', 'index.html'))) { res.sendFile(path.join(__dirname, 'dist', 'index.html')); } else { res.send('Vite Dev Server is running. Visit port 3000 to access the page.'); } }); // Start server after initial Firestore sync async function start() { await syncFromFirestore(); await autoRecoverServers(); server.listen(PORT, '0.0.0.0', () => { console.log(`Minecraft Hoster Backend API listening on http://0.0.0.0:${PORT}`); console.log(`WebSocket server is running on ws://0.0.0.0:${PORT}`); }); } start();