smp-manager / server.js
techedstudios
fix compression and view distance
5344922
Raw
History Blame Contribute Delete
63 kB
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();