diff --git a/app.js b/app.js index 82779f1..c20bc5f 100644 --- a/app.js +++ b/app.js @@ -151,6 +151,9 @@ app.locals.formatUptime = (uptime) => { // Initialize API with Socket.IO setSocketIO(io); +// Make Socket.IO available to routes +app.set('socketio', io); + // Socket.IO connection handling io.on('connection', (socket) => { console.log('Client connected:', socket.id); diff --git a/public/js/server.js b/public/js/server.js index 070c599..8118f0e 100644 --- a/public/js/server.js +++ b/public/js/server.js @@ -115,10 +115,10 @@ function updateStatusDisplay(status) { statusText.textContent = 'Starting...'; } - // For external servers, disable control buttons + // For external servers, allow stop but disable start/restart if (status.isExternal) { startBtn.disabled = true; - stopBtn.disabled = true; + stopBtn.disabled = false; // Allow stopping external servers restartBtn.disabled = true; consoleInputGroup.style.display = 'none'; } else { @@ -205,9 +205,7 @@ async function loadWorlds() { if (worlds.length === 0) { worldSelect.innerHTML = - '' + - '' + - ''; + ''; } else { worldSelect.innerHTML = ''; worlds.forEach(world => { @@ -626,4 +624,4 @@ async function downloadLogs() { console.error('Download logs error:', error); addLogEntry('error', 'Failed to download logs: ' + error.message); } -} \ No newline at end of file +} diff --git a/routes/api.js b/routes/api.js index 1f84a7f..d83fe8b 100644 --- a/routes/api.js +++ b/routes/api.js @@ -69,16 +69,6 @@ router.get('/server/status', async (req, res) => { } const isExternal = serverManager.serverProcess?.external || false; - console.log('API: serverManager.serverProcess =', serverManager.serverProcess); - console.log('API: isExternal =', isExternal); - - console.log('API endpoint returning status:', { - isRunning: status.isRunning, - players: playerList.length, // Use the actual detected player count - playerNames: playerList.map(p => p.name), - statusText: status.isRunning ? 'running' : 'stopped', - isExternal: isExternal - }); res.json({ ...status, @@ -531,4 +521,4 @@ module.exports = { setSocketIO, serverManager, configManager -}; \ No newline at end of file +}; diff --git a/routes/auth.js b/routes/auth.js index a1494d6..70ae417 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -2,6 +2,8 @@ const express = require('express'); const AuthManager = require('../utils/auth'); const { redirectIfAuthenticated } = require('../middleware/auth'); const securityLogger = require('../utils/security-logger'); +const paths = require('../utils/paths'); +const appConfig = require('../utils/app-config'); const router = express.Router(); const authManager = new AuthManager(); @@ -50,10 +52,15 @@ router.get('/register', redirectIfAuthenticated, async (req, res) => { }); } + // Detect available Luanti data directories + const detectedDirectories = paths.detectLuantiDataDirectories(); + res.render('auth/register', { title: 'Setup Administrator Account', isFirstUser: isFirstUser, - currentPage: 'register' + currentPage: 'register', + detectedDirectories: detectedDirectories, + defaultDataDir: paths.getDefaultDataDirectory() }); } catch (error) { console.error('Error checking first user:', error); @@ -119,29 +126,74 @@ router.post('/register', redirectIfAuthenticated, async (req, res) => { }); } - const { username, password, confirmPassword } = req.body; + const { username, password, confirmPassword, dataDirectory, customDataDirectory } = req.body; // Validate inputs if (!username || !password || !confirmPassword) { + const detectedDirectories = paths.detectLuantiDataDirectories(); return res.render('auth/register', { title: 'Setup Administrator Account', error: 'All fields are required', isFirstUser: true, currentPage: 'register', - formData: { username } + formData: { username }, + detectedDirectories: detectedDirectories, + defaultDataDir: paths.getDefaultDataDirectory() }); } if (password !== confirmPassword) { + const detectedDirectories = paths.detectLuantiDataDirectories(); return res.render('auth/register', { title: 'Setup Administrator Account', error: 'Passwords do not match', isFirstUser: true, currentPage: 'register', - formData: { username } + formData: { username }, + detectedDirectories: detectedDirectories, + defaultDataDir: paths.getDefaultDataDirectory() }); } + // Handle data directory selection + let selectedDataDir = dataDirectory; + if (dataDirectory === 'custom' && customDataDirectory) { + selectedDataDir = customDataDirectory; + } + + // Validate the selected data directory + if (selectedDataDir && selectedDataDir !== 'custom') { + try { + // Ensure the directory exists or can be created + const fs = require('fs'); + if (!fs.existsSync(selectedDataDir)) { + await fs.promises.mkdir(selectedDataDir, { recursive: true }); + } + + // Save the data directory to app config + await appConfig.load(); + appConfig.setDataDirectory(selectedDataDir); + await appConfig.save(); + + // Update paths to use the new directory + paths.setDataDirectory(selectedDataDir); + + console.log('Data directory set to:', selectedDataDir); + } catch (error) { + console.error('Error setting data directory:', error); + const detectedDirectories = paths.detectLuantiDataDirectories(); + return res.render('auth/register', { + title: 'Setup Administrator Account', + error: `Invalid data directory: ${error.message}`, + isFirstUser: true, + currentPage: 'register', + formData: { username }, + detectedDirectories: detectedDirectories, + defaultDataDir: paths.getDefaultDataDirectory() + }); + } + } + const user = await authManager.createUser(username, password); // Create session for new user @@ -157,6 +209,7 @@ router.post('/register', redirectIfAuthenticated, async (req, res) => { } catch (error) { console.error('Registration error:', error); + const detectedDirectories = paths.detectLuantiDataDirectories(); res.render('auth/register', { title: 'Register', error: error.message, @@ -164,7 +217,9 @@ router.post('/register', redirectIfAuthenticated, async (req, res) => { currentPage: 'register', formData: { username: req.body.username - } + }, + detectedDirectories: detectedDirectories, + defaultDataDir: paths.getDefaultDataDirectory() }); } }); diff --git a/routes/config.js b/routes/config.js index e9ffa70..0324c41 100644 --- a/routes/config.js +++ b/routes/config.js @@ -4,6 +4,7 @@ const fs = require('fs').promises; const paths = require('../utils/paths'); const ConfigParser = require('../utils/config-parser'); const appConfig = require('../utils/app-config'); +const PackageRegistry = require('../utils/package-registry'); const router = express.Router(); @@ -170,8 +171,11 @@ router.post('/update', async (req, res) => { if (newDataDir && newDataDir !== appConfig.getDataDirectory()) { try { await appConfig.setDataDirectory(newDataDir); - // Update paths to use new directory - await paths.initialize(); + // Force reload paths to use new directory + await paths.forceReload(); + // Reinitialize package registry to use new directory + const packageRegistry = new PackageRegistry(); + await packageRegistry.reinitialize(); } catch (error) { throw new Error(`Failed to update data directory: ${error.message}`); } @@ -286,6 +290,53 @@ router.get('/api/schema', (req, res) => { }); // Get current configuration (API) +// Change data directory only +router.post('/change-data-directory', async (req, res) => { + try { + const { newDataDirectory } = req.body; + + if (!newDataDirectory || !newDataDirectory.trim()) { + return res.status(400).json({ + success: false, + error: 'Data directory path is required' + }); + } + + const newDataDir = newDataDirectory.trim(); + const currentDataDir = appConfig.getDataDirectory(); + + if (newDataDir === currentDataDir) { + return res.json({ + success: true, + message: 'Data directory is already set to this path', + dataDirectory: currentDataDir + }); + } + + // Validate and set new data directory + await appConfig.load(); + await appConfig.setDataDirectory(newDataDir); + await appConfig.save(); + + // Update paths to use new directory + await paths.initialize(); + + res.json({ + success: true, + message: 'Data directory updated successfully. Please restart the application for all changes to take effect.', + dataDirectory: newDataDir, + previousDirectory: currentDataDir + }); + + } catch (error) { + console.error('Error changing data directory:', error); + res.status(500).json({ + success: false, + error: `Failed to change data directory: ${error.message}` + }); + } +}); + router.get('/api/current', async (req, res) => { try { const config = await ConfigParser.parseConfig(paths.configFile); diff --git a/routes/extensions.js b/routes/extensions.js index 4a0b4bb..87eb0f6 100644 --- a/routes/extensions.js +++ b/routes/extensions.js @@ -12,12 +12,14 @@ const router = express.Router(); const contentdb = new ContentDBClient(); const packageRegistry = new PackageRegistry(); -// Initialize package registry +// Initialize package registry - will be reinitialized when paths change packageRegistry.init().catch(console.error); // Main Extensions page - shows installed content and installer router.get('/', async (req, res) => { try { + // Ensure we're using the current data directory + await paths.initialize(); paths.ensureDirectories(); // Get installed packages from registry (games, mods, texture packs) diff --git a/routes/worlds.js b/routes/worlds.js index b273b0b..c873a3d 100644 --- a/routes/worlds.js +++ b/routes/worlds.js @@ -6,6 +6,7 @@ const { promisify } = require('util'); const paths = require('../utils/paths'); const ConfigParser = require('../utils/config-parser'); +const serverManager = require('../utils/shared-server-manager'); const router = express.Router(); @@ -39,11 +40,15 @@ router.get('/', async (req, res) => { db.close(); } catch (dbError) {} + const gameid = config.gameid || 'minetest_game'; + const gameTitle = await paths.getGameDisplayName(gameid); + worlds.push({ name: worldDir, displayName: config.server_name || worldDir, description: config.server_description || '', - gameid: config.gameid || 'minetest_game', + gameid: gameid, + gameTitle: gameTitle, creativeMode: config.creative_mode || false, enableDamage: config.enable_damage !== false, enablePvp: config.enable_pvp !== false, @@ -128,131 +133,42 @@ router.post('/create', async (req, res) => { console.log('Starting world creation for:', name, 'with gameid:', gameid); - // Create the world directory - Luanti will initialize it when the server starts - await fs.mkdir(worldPath, { recursive: true }); - console.log('Created world directory:', worldPath); - - // Create a basic world.mt file with the correct game ID - const worldConfig = `enable_damage = true -creative_mode = false -mod_storage_backend = sqlite3 -auth_backend = sqlite3 -player_backend = sqlite3 -backend = sqlite3 -gameid = ${gameid || 'minetest_game'} -world_name = ${name} -`; - - const worldConfigPath = path.join(worldPath, 'world.mt'); - await fs.writeFile(worldConfigPath, worldConfig, 'utf8'); - console.log('Created world.mt with gameid:', gameid || 'minetest_game'); - - // Create essential database files with proper schema - const sqlite3 = require('sqlite3'); - - // Create players database with correct schema - const playersDbPath = path.join(worldPath, 'players.sqlite'); - await new Promise((resolve, reject) => { - const playersDb = new sqlite3.Database(playersDbPath, (err) => { - if (err) reject(err); - else { - playersDb.serialize(() => { - playersDb.exec(`CREATE TABLE IF NOT EXISTS player ( - name TEXT PRIMARY KEY, - pitch REAL, - yaw REAL, - posX REAL, - posY REAL, - posZ REAL, - hp INTEGER, - breath INTEGER, - creation_date INTEGER, - modification_date INTEGER, - privs TEXT - )`, (err) => { - if (err) { - console.error('Error creating player table:', err); - reject(err); - } else { - console.log('Created player table in players.sqlite'); - playersDb.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(); - }); - } - }); + // Start world creation asynchronously and respond immediately + serverManager.createWorld(name, gameid || 'minetest_game') + .then(() => { + console.log('Successfully created world using Luanti:', name); + // Notify clients via WebSocket + const io = req.app.get('socketio'); + if (io) { + io.emit('worldCreated', { + worldName: name, + gameId: gameid || 'minetest_game', + success: true + }); + } + }) + .catch((error) => { + console.error('Error creating world:', error); + // Notify clients via WebSocket about the error + const io = req.app.get('socketio'); + if (io) { + io.emit('worldCreated', { + worldName: name, + gameId: gameid || 'minetest_game', + success: false, + error: error.message }); } }); - }); - // Create other essential databases - const mapDbPath = path.join(worldPath, 'map.sqlite'); - await new Promise((resolve, reject) => { - const mapDb = new sqlite3.Database(mapDbPath, (err) => { - if (err) reject(err); - else { - mapDb.serialize(() => { - mapDb.exec(`CREATE TABLE IF NOT EXISTS blocks ( - x INTEGER, - y INTEGER, - z INTEGER, - data BLOB NOT NULL, - PRIMARY KEY (x, z, y) - )`, (err) => { - if (err) { - console.error('Error creating blocks table:', err); - reject(err); - } else { - console.log('Created blocks table in map.sqlite'); - mapDb.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(); - }); - } - }); - }); - } - }); - }); - - const modStorageDbPath = path.join(worldPath, 'mod_storage.sqlite'); - await new Promise((resolve, reject) => { - const modDb = new sqlite3.Database(modStorageDbPath, (err) => { - if (err) reject(err); - else { - modDb.serialize(() => { - modDb.exec(`CREATE TABLE IF NOT EXISTS entries ( - modname TEXT NOT NULL, - key BLOB NOT NULL, - value BLOB NOT NULL, - PRIMARY KEY (modname, key) - )`, (err) => { - if (err) { - console.error('Error creating entries table:', err); - reject(err); - } else { - console.log('Created entries table in mod_storage.sqlite'); - modDb.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(); - }); - } - }); - }); - } - }); - }); - - console.log('Created essential database files with proper schema'); - - res.redirect('/worlds?created=' + encodeURIComponent(name)); + // Respond immediately with a "creating" status + res.redirect('/worlds?creating=' + encodeURIComponent(name)); } catch (error) { - console.error('Error creating world:', error); + console.error('Error starting world creation:', error); res.status(500).render('worlds/new', { title: 'Create World', currentPage: 'worlds', - error: 'Failed to create world: ' + error.message, + error: 'Failed to start world creation: ' + error.message, formData: req.body }); } diff --git a/utils/contentdb.js b/utils/contentdb.js index 6f23e50..94501e0 100644 --- a/utils/contentdb.js +++ b/utils/contentdb.js @@ -111,14 +111,45 @@ class ContentDBClient { downloadUrl = 'https://content.luanti.org' + downloadUrl; } - // Download the package - const downloadResponse = await axios.get(downloadUrl, { - responseType: 'stream', - timeout: 120000, // 2 minutes for download - headers: { - 'User-Agent': 'LuHost/1.0' + // Download the package with retry logic + let downloadResponse; + let retryCount = 0; + const maxRetries = 3; + + while (retryCount <= maxRetries) { + try { + console.log(`ContentDB: Attempting download from ${downloadUrl} (attempt ${retryCount + 1}/${maxRetries + 1})`); + downloadResponse = await axios.get(downloadUrl, { + responseType: 'stream', + timeout: 60000, // 1 minute timeout per attempt + headers: { + 'User-Agent': 'LuHost/1.0', + 'Accept': '*/*', + 'Connection': 'keep-alive' + }, + // Increase buffer limits to handle larger downloads + maxContentLength: 100 * 1024 * 1024, // 100MB + maxBodyLength: 100 * 1024 * 1024 + }); + break; // Success, exit retry loop + } catch (downloadError) { + retryCount++; + console.warn(`ContentDB: Download attempt ${retryCount} failed:`, downloadError.message); + + if (retryCount > maxRetries) { + // All retries exhausted + const errorMsg = downloadError.code === 'ECONNRESET' || downloadError.message.includes('closed') + ? 'Connection was closed by the server. This may be due to network issues or server load. Please try again later.' + : `Download failed: ${downloadError.message}`; + throw new Error(errorMsg); + } + + // Wait before retrying (exponential backoff) + const delayMs = Math.pow(2, retryCount - 1) * 1000; // 1s, 2s, 4s + console.log(`ContentDB: Retrying in ${delayMs}ms...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); } - }); + } // Create target directory await fs.mkdir(targetPath, { recursive: true }); @@ -137,10 +168,28 @@ class ContentDBClient { }); // Extract zip file - await this.extractZipFile(tempZipPath, targetPath); + try { + await this.extractZipFile(tempZipPath, targetPath); + console.log(`ContentDB: Successfully extracted zip to ${targetPath}`); + } catch (extractError) { + console.error(`ContentDB: Extraction failed:`, extractError); + // Clean up temp file before rethrowing + try { + await fs.unlink(tempZipPath); + } catch (cleanupError) { + console.warn(`ContentDB: Failed to cleanup temp file:`, cleanupError.message); + } + throw extractError; + } // Remove temp zip file - await fs.unlink(tempZipPath); + try { + await fs.unlink(tempZipPath); + console.log(`ContentDB: Cleaned up temp zip file`); + } catch (cleanupError) { + console.warn(`ContentDB: Failed to remove temp zip file:`, cleanupError.message); + // Don't throw - extraction succeeded, cleanup failure is not critical + } } else { // For non-zip files, save directly const fileName = path.basename(release.url) || 'download'; @@ -173,57 +222,151 @@ class ContentDBClient { return; } - zipfile.readEntry(); + const entries = []; - zipfile.on('entry', async (entry) => { - const entryPath = path.join(targetPath, entry.fileName); - - // Ensure the entry path is within target directory (security) - const normalizedPath = path.normalize(entryPath); - if (!normalizedPath.startsWith(path.normalize(targetPath))) { - zipfile.readEntry(); - return; - } - - if (/\/$/.test(entry.fileName)) { - // Directory entry - try { - await fs.mkdir(normalizedPath, { recursive: true }); - zipfile.readEntry(); - } catch (error) { - reject(error); + // First pass: collect all entries to analyze structure + zipfile.on('entry', (entry) => { + entries.push(entry); + zipfile.readEntry(); + }); + + zipfile.on('end', async () => { + try { + // Analyze if we have a common root directory that should be stripped + let commonRoot = null; + let shouldStripRoot = false; + + if (entries.length > 0) { + // Find files (not directories) to analyze structure + const fileEntries = entries.filter(e => !e.fileName.endsWith('/') && e.fileName.trim() !== ''); + + if (fileEntries.length > 0) { + // Check if all files are in the same top-level directory + const firstPath = fileEntries[0].fileName; + const firstSlash = firstPath.indexOf('/'); + + if (firstSlash > 0) { + const potentialRoot = firstPath.substring(0, firstSlash); + + // Check if ALL file entries start with this root directory + const allInSameRoot = fileEntries.every(entry => + entry.fileName.startsWith(potentialRoot + '/') + ); + + if (allInSameRoot) { + commonRoot = potentialRoot; + shouldStripRoot = true; + console.log(`ContentDB: Detected common root directory "${commonRoot}", will strip it during extraction`); + } + } + } } - } else { - // File entry - zipfile.openReadStream(entry, (err, readStream) => { - if (err) { - reject(err); + + zipfile.close(); + + // Second pass: reopen zip and extract files sequentially + yauzl.open(zipPath, { lazyEntries: true }, (reopenErr, newZipfile) => { + if (reopenErr) { + reject(reopenErr); return; } + + let entryIndex = 0; + + const processNextEntry = () => { + if (entryIndex >= entries.length) { + newZipfile.close(); + resolve(); + return; + } + + const entry = entries[entryIndex++]; + let fileName = entry.fileName; + + // Strip common root if detected + if (shouldStripRoot && commonRoot) { + if (fileName === commonRoot || fileName === commonRoot + '/') { + processNextEntry(); // Skip the root directory itself + return; + } + if (fileName.startsWith(commonRoot + '/')) { + fileName = fileName.substring(commonRoot.length + 1); + } + } + + // Skip empty filenames + if (!fileName || fileName.trim() === '') { + processNextEntry(); + return; + } + + const entryPath = path.join(targetPath, fileName); + + // Ensure the entry path is within target directory (security) + const normalizedPath = path.normalize(entryPath); + if (!normalizedPath.startsWith(path.normalize(targetPath))) { + processNextEntry(); + return; + } - // Ensure parent directory exists - const parentDir = path.dirname(normalizedPath); - fs.mkdir(parentDir, { recursive: true }) - .then(() => { - const writeStream = require('fs').createWriteStream(normalizedPath); - readStream.pipe(writeStream); - - writeStream.on('finish', () => { - zipfile.readEntry(); + if (fileName.endsWith('/')) { + // Directory entry + fs.mkdir(normalizedPath, { recursive: true }) + .then(() => processNextEntry()) + .catch(reject); + } else { + // File entry + newZipfile.openReadStream(entry, async (streamErr, readStream) => { + if (streamErr) { + newZipfile.close(); + reject(streamErr); + return; + } + + try { + // Ensure parent directory exists + const parentDir = path.dirname(normalizedPath); + await fs.mkdir(parentDir, { recursive: true }); + + const writeStream = require('fs').createWriteStream(normalizedPath); + readStream.pipe(writeStream); + + writeStream.on('finish', () => { + processNextEntry(); + }); + + writeStream.on('error', (writeError) => { + newZipfile.close(); + reject(writeError); + }); + } catch (mkdirError) { + newZipfile.close(); + reject(mkdirError); + } }); - - writeStream.on('error', reject); - }) - .catch(reject); + } + }; + + newZipfile.on('error', (zipError) => { + newZipfile.close(); + reject(zipError); + }); + + processNextEntry(); }); + + } catch (error) { + zipfile.close(); + reject(error); } }); - zipfile.on('end', () => { - resolve(); + zipfile.on('error', (error) => { + zipfile.close(); + reject(error); }); - - zipfile.on('error', reject); + + zipfile.readEntry(); }); }); } diff --git a/utils/package-registry.js b/utils/package-registry.js index abda422..c49ef02 100644 --- a/utils/package-registry.js +++ b/utils/package-registry.js @@ -3,12 +3,20 @@ const path = require('path'); const fs = require('fs').promises; class PackageRegistry { - constructor(dbPath = 'data/packages.db') { + constructor(dbPath = null) { + // If no dbPath provided, we'll set it during init based on current data directory this.dbPath = dbPath; this.db = null; } async init() { + // Set database path based on current data directory if not already set + if (!this.dbPath) { + const paths = require('./paths'); + await paths.initialize(); + this.dbPath = path.join(paths.minetestDir, 'luhost_packages.db'); + } + // Ensure data directory exists const dir = path.dirname(this.dbPath); await fs.mkdir(dir, { recursive: true }); @@ -24,6 +32,24 @@ class PackageRegistry { }); } + async reinitialize() { + // Close existing database connection + if (this.db) { + await new Promise((resolve) => { + this.db.close((err) => { + if (err) console.error('Error closing database:', err); + resolve(); + }); + }); + } + + // Clear the path so it gets recalculated + this.dbPath = null; + + // Reinitialize with new path + return this.init(); + } + async createTables() { const sql = ` CREATE TABLE IF NOT EXISTS installed_packages ( diff --git a/utils/paths.js b/utils/paths.js index f3f2167..97eac0c 100644 --- a/utils/paths.js +++ b/utils/paths.js @@ -14,24 +14,104 @@ class LuantiPaths { await appConfig.load(); const configuredDataDir = appConfig.getDataDirectory(); this.setDataDirectory(configuredDataDir); + console.log(`Paths initialized with data directory: ${configuredDataDir}`); + } + + async forceReload() { + // Force reload the app config and reinitialize paths + delete require.cache[require.resolve('./app-config')]; + const appConfig = require('./app-config'); + await appConfig.load(); + const configuredDataDir = appConfig.getDataDirectory(); + this.setDataDirectory(configuredDataDir); + console.log(`Paths force reloaded with data directory: ${configuredDataDir}`); } getDefaultDataDirectory() { - // Check for common Luanti data directories + const validDirs = this.detectLuantiDataDirectories(); + return validDirs.length > 0 ? validDirs[0].path : path.join(os.homedir(), '.minetest'); + } + + detectLuantiDataDirectories() { const homeDir = os.homedir(); - const possibleDirs = [ - path.join(homeDir, '.luanti'), - path.join(homeDir, '.minetest') + const candidateDirs = [ + // Flatpak installations + { + path: path.join(homeDir, '.var/app/org.luanti.luanti/.minetest'), + type: 'Flatpak (org.luanti.luanti)', + description: 'Luanti installed via Flatpak' + }, + { + path: path.join(homeDir, '.var/app/net.minetest.Minetest/.minetest'), + type: 'Flatpak (net.minetest.Minetest)', + description: 'Minetest installed via Flatpak' + }, + // Snap installations (they typically use ~/snap/app-name/current/.local/share/minetest) + { + path: path.join(homeDir, 'snap/luanti/current/.local/share/minetest'), + type: 'Snap (luanti)', + description: 'Luanti installed via Snap' + }, + { + path: path.join(homeDir, 'snap/minetest/current/.local/share/minetest'), + type: 'Snap (minetest)', + description: 'Minetest installed via Snap' + }, + // System package installations + { + path: path.join(homeDir, '.luanti'), + type: 'System Package', + description: 'System-wide Luanti installation' + }, + { + path: path.join(homeDir, '.minetest'), + type: 'System Package', + description: 'System-wide Minetest installation' + }, + // AppImage or manual installations might use these + { + path: path.join(homeDir, '.local/share/minetest'), + type: 'User Installation', + description: 'User-local installation' + } ]; - // Use the first one that exists, or default to .minetest - for (const dir of possibleDirs) { - if (fs.existsSync(dir)) { - return dir; + // Check which directories exist and contain expected Luanti files + const validDirs = []; + for (const candidate of candidateDirs) { + if (fs.existsSync(candidate.path)) { + // Check if it looks like a valid Luanti data directory + const hasConfig = fs.existsSync(path.join(candidate.path, 'minetest.conf')); + const hasWorlds = fs.existsSync(path.join(candidate.path, 'worlds')); + const hasDebug = fs.existsSync(path.join(candidate.path, 'debug.txt')); + + // Even if some files don't exist, the directory might be valid if it exists + // (Luanti creates files on first run) + candidate.confidence = (hasConfig ? 1 : 0) + (hasWorlds ? 1 : 0) + (hasDebug ? 0.5 : 0); + candidate.exists = true; + candidate.hasConfig = hasConfig; + candidate.hasWorlds = hasWorlds; + candidate.hasDebug = hasDebug; + + validDirs.push(candidate); } } + + // Sort by confidence (directories with more Luanti files first) + validDirs.sort((a, b) => b.confidence - a.confidence); - return path.join(homeDir, '.minetest'); + return validDirs; + } + + async getGameDisplayName(gameId) { + try { + const games = await this.getInstalledGames(); + const game = games.find(g => g.name === gameId); + return game ? game.title : gameId; + } catch (error) { + console.error('Error getting game display name:', error); + return gameId; + } } setDataDirectory(dataDir) { @@ -113,12 +193,11 @@ class LuantiPaths { async getInstalledGames() { const games = []; + // For Flatpak and other sandboxed installations, only look in the configured data directory + // System installations might also have access to additional directories, but we should + // primarily focus on the configured data directory to match what Luanti actually sees const possibleGameDirs = [ - this.gamesDir, // User games directory - '/usr/share/luanti/games', // System games directory - '/usr/share/minetest/games', // Legacy system games directory - path.join(process.env.HOME || '/root', '.minetest/games'), // Explicit user path - path.join(process.env.HOME || '/root', '.luanti/games') // New user path + this.gamesDir // Only the configured games directory ]; for (const gameDir of possibleGameDirs) { diff --git a/utils/server-manager.js b/utils/server-manager.js index 354285b..e0a5dac 100644 --- a/utils/server-manager.js +++ b/utils/server-manager.js @@ -22,6 +22,7 @@ class ServerManager extends EventEmitter { }; this.debugFileWatcher = null; this.lastDebugFilePosition = 0; + this.lastStopTime = 0; // Track when we last stopped a server } async getServerStatus() { @@ -58,10 +59,17 @@ class ServerManager extends EventEmitter { } } - // Always check for externally running Luanti servers if we don't have a running one - if (!this.isRunning) { + // Only check for external servers if we truly have no server process at all + // This prevents the state confusion that was causing managed servers to be marked as external + // Also add a cooldown period after stopping a managed server to prevent race conditions + const timeSinceLastStop = Date.now() - this.lastStopTime; + const cooldownPeriod = 5000; // 5 seconds cooldown after stopping a server + + if (!this.serverProcess && timeSinceLastStop > cooldownPeriod) { const externalServer = await this.detectExternalLuantiServer(); if (externalServer) { + console.log(`ServerManager: Detected truly external server (PID: ${externalServer.pid})`); + this.isRunning = true; this.isReady = true; this.startTime = externalServer.startTime; @@ -142,9 +150,9 @@ class ServerManager extends EventEmitter { } // Check if minetest/luanti executable exists - const executable = await this.findMinetestExecutable(); + const executableInfo = await this.findMinetestExecutable(); - this.serverProcess = spawn(executable, args, { + this.serverProcess = spawn(executableInfo.command, [...executableInfo.args, ...args], { cwd: paths.minetestDir, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -206,35 +214,189 @@ class ServerManager extends EventEmitter { throw new Error('Server is not running'); } + const pid = this.serverProcess.pid; + const isExternal = this.serverProcess.external; + + console.log(`ServerManager: Stopping server (PID: ${pid}, External: ${isExternal}, Force: ${force})`); + return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (this.serverProcess && this.isRunning) { - // Force kill if graceful shutdown failed - this.serverProcess.kill('SIGKILL'); - resolve({ success: true, message: 'Server force-stopped' }); + let timeoutHandle = null; + let resolved = false; + + const cleanup = (message) => { + if (resolved) return; + resolved = true; + + if (timeoutHandle) { + clearTimeout(timeoutHandle); } - }, 10000); // 10 second timeout + + // Clean up state immediately + this.cleanupServerState(); + + // Add a delay to prevent race conditions with external detection + setTimeout(() => { + resolve({ success: true, message }); + }, 2000); + }; - this.serverProcess.on('exit', () => { - clearTimeout(timeout); - resolve({ success: true, message: 'Server stopped gracefully' }); - }); + // Set timeout for force kill + timeoutHandle = setTimeout(() => { + console.log(`ServerManager: Timeout reached, force-killing process tree for ${pid}`); + this.killProcessTree(pid, () => { + console.log(`ServerManager: Process tree killed for PID ${pid} (timeout)`); + cleanup('Server force-stopped after timeout'); + }); + }, isExternal ? 3000 : 10000); - // Try graceful shutdown first - if (force) { - this.serverProcess.kill('SIGTERM'); + // Set up exit handler for managed processes + if (this.serverProcess.on && !isExternal) { + this.serverProcess.on('exit', () => { + console.log('ServerManager: Managed server process exited'); + cleanup('Server stopped gracefully'); + }); + } + + // For external servers or force stop, kill immediately + if (isExternal || force) { + console.log(`ServerManager: Killing process tree for PID ${pid}`); + this.killProcessTree(pid, () => { + console.log(`ServerManager: Process tree killed for PID ${pid}`); + cleanup('Server stopped'); + }); } else { - // Send shutdown command to server + // Try graceful shutdown first for managed servers try { this.serverProcess.stdin.write('/shutdown\n'); - } catch (error) { - // If stdin fails, use SIGTERM - this.serverProcess.kill('SIGTERM'); + console.log('ServerManager: Sent shutdown command to server'); + } catch (stdinError) { + console.warn('Failed to send shutdown command, using SIGTERM:', stdinError.message); + this.killProcessTree(pid, () => { + console.log(`ServerManager: Process tree killed for PID ${pid} (after stdin failure)`); + cleanup('Server stopped'); + }); } } }); } + killProcessTree(pid, callback) { + console.log(`ServerManager: Attempting to kill process tree for PID ${pid}`); + + const { spawn } = require('child_process'); + + // First, try to kill the process gracefully with SIGTERM + this.killProcessRecursive(pid, 'TERM', (termSuccess) => { + if (termSuccess) { + console.log('ServerManager: Process terminated gracefully'); + callback(); + return; + } + + // If SIGTERM didn't work, wait a moment and try SIGKILL + console.log('ServerManager: SIGTERM failed, trying SIGKILL...'); + setTimeout(() => { + this.killProcessRecursive(pid, 'KILL', (killSuccess) => { + if (killSuccess) { + console.log('ServerManager: Process force killed'); + } else { + console.warn('ServerManager: Could not kill process'); + } + callback(); + }); + }, 2000); + }); + } + + killProcessRecursive(pid, signal, callback) { + const { spawn } = require('child_process'); + + // Get all child processes first + const pstree = spawn('pstree', ['-p', pid.toString()]); + let pids = [pid]; + + let pstreeOutput = ''; + pstree.stdout.on('data', (data) => { + pstreeOutput += data.toString(); + }); + + pstree.on('close', (code) => { + // Extract all PIDs from pstree output + const pidMatches = pstreeOutput.match(/\((\d+)\)/g); + if (pidMatches) { + const childPids = pidMatches.map(match => parseInt(match.slice(1, -1))); + // Add child PIDs to our list, avoiding duplicates + childPids.forEach(childPid => { + if (!pids.includes(childPid)) { + pids.push(childPid); + } + }); + } + + console.log(`ServerManager: Found ${pids.length} processes to kill:`, pids); + + // Kill all processes in reverse order (children first, then parent) + pids.reverse(); + + let processesKilled = 0; + let processesTotal = pids.length; + + const killNextProcess = (index) => { + if (index >= pids.length) { + // All done + console.log(`ServerManager: Killed ${processesKilled}/${processesTotal} processes`); + callback(processesKilled > 0); + return; + } + + const currentPid = pids[index]; + console.log(`ServerManager: Sending SIG${signal} to PID ${currentPid}`); + + try { + process.kill(currentPid, `SIG${signal}`); + processesKilled++; + console.log(`ServerManager: Successfully sent SIG${signal} to PID ${currentPid}`); + } catch (error) { + console.log(`ServerManager: Could not kill PID ${currentPid}:`, error.message); + } + + // Move to next process after a short delay + setTimeout(() => killNextProcess(index + 1), 100); + }; + + killNextProcess(0); + }); + + pstree.on('error', (error) => { + console.warn('ServerManager: pstree failed, falling back to simple kill:', error.message); + + // Fallback: just try to kill the main PID + try { + process.kill(pid, `SIG${signal}`); + console.log(`ServerManager: Successfully sent SIG${signal} to PID ${pid} (fallback)`); + callback(true); + } catch (error) { + console.log(`ServerManager: Could not kill PID ${pid}:`, error.message); + callback(false); + } + }); + } + + cleanupServerState() { + console.log('ServerManager: Cleaning up server state'); + this.isRunning = false; + this.isReady = false; + this.serverProcess = null; + this.startTime = null; + this.lastStopTime = Date.now(); // Record when we stopped the server + this.stopDebugFileMonitoring(); + + // Reset player stats when server stops + this.serverStats.players = 0; + this.serverStats.memoryUsage = 0; + this.serverStats.cpuUsage = 0; + } + async restartServer(worldName = null) { if (this.isRunning) { await this.stopServer(); @@ -262,66 +424,153 @@ class ServerManager extends EventEmitter { } async findMinetestExecutable() { - // Whitelist of allowed executable names to prevent command injection - const allowedExecutables = ['luanti', 'minetest', 'minetestserver']; - const foundExecutables = []; + // Check different installation methods for Luanti + const installationMethods = [ + // System packages + { command: 'luanti', args: [] }, + { command: 'minetest', args: [] }, + { command: 'minetestserver', args: [] }, + // Flatpak + { command: 'flatpak', args: ['run', 'org.luanti.luanti'] }, + { command: 'flatpak', args: ['run', 'net.minetest.Minetest'] }, + // Snap + { command: 'snap', args: ['run', 'luanti'] }, + { command: 'snap', args: ['run', 'minetest'] } + ]; - for (const name of allowedExecutables) { + for (const method of installationMethods) { try { - // Validate executable name against whitelist - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - continue; - } - - const execPath = await new Promise((resolve, reject) => { - // Use spawn instead of exec to avoid command injection + // Test if the command exists and works + const testResult = await new Promise((resolve, reject) => { const { spawn } = require('child_process'); - const whichProcess = spawn('which', [name], { stdio: ['ignore', 'pipe', 'pipe'] }); + const testProcess = spawn(method.command, [...method.args, '--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000 + }); let stdout = ''; let stderr = ''; - whichProcess.stdout.on('data', (data) => { + testProcess.stdout.on('data', (data) => { stdout += data.toString(); }); - whichProcess.stderr.on('data', (data) => { + testProcess.stderr.on('data', (data) => { stderr += data.toString(); }); - whichProcess.on('close', (code) => { - if (code === 0) { - resolve(stdout.trim()); + testProcess.on('close', (code) => { + if ((code === 0 || code === null) && (stdout.includes('Luanti') || stdout.includes('Minetest'))) { + resolve({ command: method.command, args: method.args, output: stdout }); } else { - reject(new Error(`which command failed: ${stderr}`)); + reject(new Error(`Command failed with code ${code}`)); } }); - whichProcess.on('error', (error) => { + testProcess.on('error', (error) => { reject(error); }); }); - // Validate that the returned path is safe - if (execPath && path.isAbsolute(execPath)) { - foundExecutables.push({ name, path: execPath }); - this.addLogLine('info', `Found executable: ${name} at ${execPath}`); - return execPath; // Return the full path for security - } + const executableInfo = { + command: testResult.command, + args: testResult.args, + fullCommand: testResult.args.length > 0 ? `${testResult.command} ${testResult.args.join(' ')}` : testResult.command + }; + + this.addLogLine('info', `Found Luanti: ${executableInfo.fullCommand}`); + return executableInfo; + } catch (error) { - // Continue to next possibility + // Continue to next method + continue; } } - // Provide detailed error message - const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` + - `Searched for: ${allowedExecutables.join(', ')}\n` + - `Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`; + // Provide detailed error message with setup instructions + const errorMsg = `Luanti/Minetest not found or not working properly.\n\n` + + `Please install Luanti using one of these methods:\n` + + `• System package: sudo apt install luanti (Ubuntu/Debian)\n` + + `• Flatpak: flatpak install flathub org.luanti.luanti\n` + + `• Snap: sudo snap install luanti\n` + + `• Download from: https://www.luanti.org/downloads/\n\n` + + `This web interface requires Luanti to manage worlds and run servers.`; this.addLogLine('error', errorMsg); throw new Error(errorMsg); } + async createWorld(worldName, gameId = 'minetest_game') { + try { + const executableInfo = await this.findMinetestExecutable(); + const worldPath = paths.getWorldPath(worldName); + + // Create world using Luanti --world command + // This will initialize the world properly with the correct structure + const createWorldArgs = [ + '--world', worldPath, + '--gameid', gameId, + '--server', + '--go', // Skip main menu + '--quiet' // Reduce output + ]; + + this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`); + + return new Promise((resolve, reject) => { + const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], { + cwd: paths.minetestDir, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000 // 30 second timeout + }); + + let stdout = ''; + let stderr = ''; + + createProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + createProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + // Stop the server after a short time - we just want to initialize the world + // Complex games like VoxeLibre/MineCLone2 need more time to initialize + const timeout = gameId === 'mineclone2' || gameId.includes('clone') || gameId.includes('voxel') ? 8000 : 3000; + this.addLogLine('info', `Using ${timeout}ms timeout for game: ${gameId}`); + + setTimeout(() => { + if (createProcess && !createProcess.killed) { + createProcess.kill('SIGTERM'); + } + }, timeout); + + createProcess.on('close', (code) => { + this.addLogLine('info', `World creation process finished with code: ${code}`); + + // Check if world directory was created successfully + if (fsSync.existsSync(worldPath)) { + this.addLogLine('info', `World "${worldName}" created successfully`); + resolve({ success: true, worldPath }); + } else { + this.addLogLine('error', `World directory not created: ${worldPath}`); + reject(new Error(`Failed to create world directory: ${worldPath}`)); + } + }); + + createProcess.on('error', (error) => { + this.addLogLine('error', `World creation failed: ${error.message}`); + reject(error); + }); + }); + + } catch (error) { + this.addLogLine('error', `Failed to create world: ${error.message}`); + throw error; + } + } + addLogLine(type, content) { const timestamp = new Date().toISOString(); const logEntry = { @@ -666,7 +915,6 @@ class ServerManager extends EventEmitter { const playerData = new Map(); // Map to store player name -> player info const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5) - console.log('DEBUG: Looking for players active since:', cutoffTime.toISOString()); for (const line of lines.reverse()) { // Parse timestamp from log line @@ -688,7 +936,6 @@ class ServerManager extends EventEmitter { const playerName = match[1]; const actionDescription = match[2] || type; - console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription); // Filter out obvious non-player names and false positives if (!playerName.includes('Entity') && @@ -701,7 +948,6 @@ class ServerManager extends EventEmitter { playerName.length < 20 && /^[a-zA-Z0-9_]+$/.test(playerName)) { - console.log('DEBUG: Player passed filters:', playerName); // Update player data with most recent activity if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) { @@ -736,7 +982,6 @@ class ServerManager extends EventEmitter { }); } } else { - console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation'); } } } diff --git a/views/auth/register.ejs b/views/auth/register.ejs index 6d68937..392caa0 100644 --- a/views/auth/register.ejs +++ b/views/auth/register.ejs @@ -67,6 +67,53 @@ const body = ` + ${isFirstUser ? ` +
+ + + + Choose the correct Luanti data directory based on your installation method. + Directories with ✓ marks have existing Luanti files. + + + +
+ +
+

⚠️ Important: Data Directory Selection

+

The data directory must match where your Luanti installation stores its data:

+ +

Choosing the wrong directory will prevent the web interface from managing your worlds and server properly.

+
+ ` : ''} +
Already have an account? @@ -109,6 +156,24 @@ document.getElementById('password').addEventListener('input', function() { confirmPassword.dispatchEvent(new Event('input')); } }); + +// Handle custom data directory selection +const dataDirectorySelect = document.getElementById('dataDirectory'); +const customDirGroup = document.getElementById('customDirGroup'); +const customDirInput = document.getElementById('customDataDirectory'); + +if (dataDirectorySelect && customDirGroup) { + dataDirectorySelect.addEventListener('change', function() { + if (this.value === 'custom') { + customDirGroup.style.display = 'block'; + customDirInput.required = true; + } else { + customDirGroup.style.display = 'none'; + customDirInput.required = false; + customDirInput.value = ''; + } + }); +} `; %> diff --git a/views/contentdb/index.ejs b/views/contentdb/index.ejs index 8e4f852..da229f6 100644 --- a/views/contentdb/index.ejs +++ b/views/contentdb/index.ejs @@ -279,13 +279,13 @@ document.addEventListener('DOMContentLoaded', function() { const result = await response.json(); if (result.success) { - showStatus(result.message + ' ✅', 'success', false); + showStatus(result.message + ' ✅ Redirecting to Extensions...', 'success', false); clearForm(); - // Auto-hide success message after 5 seconds + // Redirect to extensions page after 2 seconds to show the newly installed package setTimeout(() => { - installStatus.style.display = 'none'; - }, 5000); + window.location.href = '/extensions'; + }, 2000); } else { showStatus(result.error || 'Installation failed', 'error', false); } diff --git a/views/dashboard.ejs b/views/dashboard.ejs index dc57131..9f880a6 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -22,7 +22,7 @@ const body = `
${stats.minetestDir}
-
Minetest Directory
+
Data Directory
@@ -95,7 +95,7 @@ const body = ` ${systemInfo.nodeVersion} - Minetest Directory + Data Directory ${stats.minetestDir} diff --git a/views/extensions/index.ejs b/views/extensions/index.ejs index 6b708f5..8c42462 100644 --- a/views/extensions/index.ejs +++ b/views/extensions/index.ejs @@ -31,50 +31,17 @@ const body = ` - +
-
-

⚡ Quick Install

-
-
-
- ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} -
- - -
-
- -
- - - - -
- -
- -
- - - - -
+
@@ -431,231 +398,6 @@ const body = `