Spaces:
Sleeping
Sleeping
| 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(); | |