Fix server management issues and improve overall stability
Major server management fixes: - Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill() - Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings) - Add 5-second cooldown after server stop to prevent race conditions with external detection - Enable Stop Server button for external servers in UI - Implement proper timeout handling with process tree killing ContentDB improvements: - Fix download retry logic and "closed" error by preventing concurrent zip extraction - Implement smart root directory detection and stripping during package extraction - Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games) World creation fixes: - Make world creation asynchronous to prevent browser hangs - Add WebSocket notifications for world creation completion status Other improvements: - Remove excessive debug logging - Improve error handling and user feedback throughout the application - Clean up temporary files and unnecessary logging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
app.js
3
app.js
@@ -151,6 +151,9 @@ app.locals.formatUptime = (uptime) => {
|
|||||||
// Initialize API with Socket.IO
|
// Initialize API with Socket.IO
|
||||||
setSocketIO(io);
|
setSocketIO(io);
|
||||||
|
|
||||||
|
// Make Socket.IO available to routes
|
||||||
|
app.set('socketio', io);
|
||||||
|
|
||||||
// Socket.IO connection handling
|
// Socket.IO connection handling
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
console.log('Client connected:', socket.id);
|
console.log('Client connected:', socket.id);
|
||||||
|
@@ -115,10 +115,10 @@ function updateStatusDisplay(status) {
|
|||||||
statusText.textContent = 'Starting...';
|
statusText.textContent = 'Starting...';
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external servers, disable control buttons
|
// For external servers, allow stop but disable start/restart
|
||||||
if (status.isExternal) {
|
if (status.isExternal) {
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
stopBtn.disabled = true;
|
stopBtn.disabled = false; // Allow stopping external servers
|
||||||
restartBtn.disabled = true;
|
restartBtn.disabled = true;
|
||||||
consoleInputGroup.style.display = 'none';
|
consoleInputGroup.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
@@ -205,9 +205,7 @@ async function loadWorlds() {
|
|||||||
|
|
||||||
if (worlds.length === 0) {
|
if (worlds.length === 0) {
|
||||||
worldSelect.innerHTML =
|
worldSelect.innerHTML =
|
||||||
'<option value="">No worlds found - server will create default world</option>' +
|
'<option value="" disabled>No worlds found</option>';
|
||||||
'<option value="" disabled>───────────────────</option>' +
|
|
||||||
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
|
|
||||||
} else {
|
} else {
|
||||||
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
||||||
worlds.forEach(world => {
|
worlds.forEach(world => {
|
||||||
|
@@ -69,16 +69,6 @@ router.get('/server/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isExternal = serverManager.serverProcess?.external || false;
|
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({
|
res.json({
|
||||||
...status,
|
...status,
|
||||||
|
@@ -2,6 +2,8 @@ const express = require('express');
|
|||||||
const AuthManager = require('../utils/auth');
|
const AuthManager = require('../utils/auth');
|
||||||
const { redirectIfAuthenticated } = require('../middleware/auth');
|
const { redirectIfAuthenticated } = require('../middleware/auth');
|
||||||
const securityLogger = require('../utils/security-logger');
|
const securityLogger = require('../utils/security-logger');
|
||||||
|
const paths = require('../utils/paths');
|
||||||
|
const appConfig = require('../utils/app-config');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const authManager = new AuthManager();
|
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', {
|
res.render('auth/register', {
|
||||||
title: 'Setup Administrator Account',
|
title: 'Setup Administrator Account',
|
||||||
isFirstUser: isFirstUser,
|
isFirstUser: isFirstUser,
|
||||||
currentPage: 'register'
|
currentPage: 'register',
|
||||||
|
detectedDirectories: detectedDirectories,
|
||||||
|
defaultDataDir: paths.getDefaultDataDirectory()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking first user:', 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
|
// Validate inputs
|
||||||
if (!username || !password || !confirmPassword) {
|
if (!username || !password || !confirmPassword) {
|
||||||
|
const detectedDirectories = paths.detectLuantiDataDirectories();
|
||||||
return res.render('auth/register', {
|
return res.render('auth/register', {
|
||||||
title: 'Setup Administrator Account',
|
title: 'Setup Administrator Account',
|
||||||
error: 'All fields are required',
|
error: 'All fields are required',
|
||||||
isFirstUser: true,
|
isFirstUser: true,
|
||||||
currentPage: 'register',
|
currentPage: 'register',
|
||||||
formData: { username }
|
formData: { username },
|
||||||
|
detectedDirectories: detectedDirectories,
|
||||||
|
defaultDataDir: paths.getDefaultDataDirectory()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
|
const detectedDirectories = paths.detectLuantiDataDirectories();
|
||||||
return res.render('auth/register', {
|
return res.render('auth/register', {
|
||||||
title: 'Setup Administrator Account',
|
title: 'Setup Administrator Account',
|
||||||
error: 'Passwords do not match',
|
error: 'Passwords do not match',
|
||||||
isFirstUser: true,
|
isFirstUser: true,
|
||||||
currentPage: 'register',
|
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);
|
const user = await authManager.createUser(username, password);
|
||||||
|
|
||||||
// Create session for new user
|
// Create session for new user
|
||||||
@@ -157,6 +209,7 @@ router.post('/register', redirectIfAuthenticated, async (req, res) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration error:', error);
|
console.error('Registration error:', error);
|
||||||
|
|
||||||
|
const detectedDirectories = paths.detectLuantiDataDirectories();
|
||||||
res.render('auth/register', {
|
res.render('auth/register', {
|
||||||
title: 'Register',
|
title: 'Register',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -164,7 +217,9 @@ router.post('/register', redirectIfAuthenticated, async (req, res) => {
|
|||||||
currentPage: 'register',
|
currentPage: 'register',
|
||||||
formData: {
|
formData: {
|
||||||
username: req.body.username
|
username: req.body.username
|
||||||
}
|
},
|
||||||
|
detectedDirectories: detectedDirectories,
|
||||||
|
defaultDataDir: paths.getDefaultDataDirectory()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
|
|||||||
const paths = require('../utils/paths');
|
const paths = require('../utils/paths');
|
||||||
const ConfigParser = require('../utils/config-parser');
|
const ConfigParser = require('../utils/config-parser');
|
||||||
const appConfig = require('../utils/app-config');
|
const appConfig = require('../utils/app-config');
|
||||||
|
const PackageRegistry = require('../utils/package-registry');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -170,8 +171,11 @@ router.post('/update', async (req, res) => {
|
|||||||
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
|
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
|
||||||
try {
|
try {
|
||||||
await appConfig.setDataDirectory(newDataDir);
|
await appConfig.setDataDirectory(newDataDir);
|
||||||
// Update paths to use new directory
|
// Force reload paths to use new directory
|
||||||
await paths.initialize();
|
await paths.forceReload();
|
||||||
|
// Reinitialize package registry to use new directory
|
||||||
|
const packageRegistry = new PackageRegistry();
|
||||||
|
await packageRegistry.reinitialize();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to update data directory: ${error.message}`);
|
throw new Error(`Failed to update data directory: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -286,6 +290,53 @@ router.get('/api/schema', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get current configuration (API)
|
// 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) => {
|
router.get('/api/current', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = await ConfigParser.parseConfig(paths.configFile);
|
const config = await ConfigParser.parseConfig(paths.configFile);
|
||||||
|
@@ -12,12 +12,14 @@ const router = express.Router();
|
|||||||
const contentdb = new ContentDBClient();
|
const contentdb = new ContentDBClient();
|
||||||
const packageRegistry = new PackageRegistry();
|
const packageRegistry = new PackageRegistry();
|
||||||
|
|
||||||
// Initialize package registry
|
// Initialize package registry - will be reinitialized when paths change
|
||||||
packageRegistry.init().catch(console.error);
|
packageRegistry.init().catch(console.error);
|
||||||
|
|
||||||
// Main Extensions page - shows installed content and installer
|
// Main Extensions page - shows installed content and installer
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Ensure we're using the current data directory
|
||||||
|
await paths.initialize();
|
||||||
paths.ensureDirectories();
|
paths.ensureDirectories();
|
||||||
|
|
||||||
// Get installed packages from registry (games, mods, texture packs)
|
// Get installed packages from registry (games, mods, texture packs)
|
||||||
|
152
routes/worlds.js
152
routes/worlds.js
@@ -6,6 +6,7 @@ const { promisify } = require('util');
|
|||||||
|
|
||||||
const paths = require('../utils/paths');
|
const paths = require('../utils/paths');
|
||||||
const ConfigParser = require('../utils/config-parser');
|
const ConfigParser = require('../utils/config-parser');
|
||||||
|
const serverManager = require('../utils/shared-server-manager');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -39,11 +40,15 @@ router.get('/', async (req, res) => {
|
|||||||
db.close();
|
db.close();
|
||||||
} catch (dbError) {}
|
} catch (dbError) {}
|
||||||
|
|
||||||
|
const gameid = config.gameid || 'minetest_game';
|
||||||
|
const gameTitle = await paths.getGameDisplayName(gameid);
|
||||||
|
|
||||||
worlds.push({
|
worlds.push({
|
||||||
name: worldDir,
|
name: worldDir,
|
||||||
displayName: config.server_name || worldDir,
|
displayName: config.server_name || worldDir,
|
||||||
description: config.server_description || '',
|
description: config.server_description || '',
|
||||||
gameid: config.gameid || 'minetest_game',
|
gameid: gameid,
|
||||||
|
gameTitle: gameTitle,
|
||||||
creativeMode: config.creative_mode || false,
|
creativeMode: config.creative_mode || false,
|
||||||
enableDamage: config.enable_damage !== false,
|
enableDamage: config.enable_damage !== false,
|
||||||
enablePvp: config.enable_pvp !== 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);
|
console.log('Starting world creation for:', name, 'with gameid:', gameid);
|
||||||
|
|
||||||
// Create the world directory - Luanti will initialize it when the server starts
|
// Start world creation asynchronously and respond immediately
|
||||||
await fs.mkdir(worldPath, { recursive: true });
|
serverManager.createWorld(name, gameid || 'minetest_game')
|
||||||
console.log('Created world directory:', worldPath);
|
.then(() => {
|
||||||
|
console.log('Successfully created world using Luanti:', name);
|
||||||
// Create a basic world.mt file with the correct game ID
|
// Notify clients via WebSocket
|
||||||
const worldConfig = `enable_damage = true
|
const io = req.app.get('socketio');
|
||||||
creative_mode = false
|
if (io) {
|
||||||
mod_storage_backend = sqlite3
|
io.emit('worldCreated', {
|
||||||
auth_backend = sqlite3
|
worldName: name,
|
||||||
player_backend = sqlite3
|
gameId: gameid || 'minetest_game',
|
||||||
backend = sqlite3
|
success: true
|
||||||
gameid = ${gameid || 'minetest_game'}
|
});
|
||||||
world_name = ${name}
|
}
|
||||||
`;
|
})
|
||||||
|
.catch((error) => {
|
||||||
const worldConfigPath = path.join(worldPath, 'world.mt');
|
console.error('Error creating world:', error);
|
||||||
await fs.writeFile(worldConfigPath, worldConfig, 'utf8');
|
// Notify clients via WebSocket about the error
|
||||||
console.log('Created world.mt with gameid:', gameid || 'minetest_game');
|
const io = req.app.get('socketio');
|
||||||
|
if (io) {
|
||||||
// Create essential database files with proper schema
|
io.emit('worldCreated', {
|
||||||
const sqlite3 = require('sqlite3');
|
worldName: name,
|
||||||
|
gameId: gameid || 'minetest_game',
|
||||||
// Create players database with correct schema
|
success: false,
|
||||||
const playersDbPath = path.join(worldPath, 'players.sqlite');
|
error: error.message
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Create other essential databases
|
// Respond immediately with a "creating" status
|
||||||
const mapDbPath = path.join(worldPath, 'map.sqlite');
|
res.redirect('/worlds?creating=' + encodeURIComponent(name));
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating world:', error);
|
console.error('Error starting world creation:', error);
|
||||||
res.status(500).render('worlds/new', {
|
res.status(500).render('worlds/new', {
|
||||||
title: 'Create World',
|
title: 'Create World',
|
||||||
currentPage: 'worlds',
|
currentPage: 'worlds',
|
||||||
error: 'Failed to create world: ' + error.message,
|
error: 'Failed to start world creation: ' + error.message,
|
||||||
formData: req.body
|
formData: req.body
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -111,14 +111,45 @@ class ContentDBClient {
|
|||||||
downloadUrl = 'https://content.luanti.org' + downloadUrl;
|
downloadUrl = 'https://content.luanti.org' + downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the package
|
// Download the package with retry logic
|
||||||
const downloadResponse = await axios.get(downloadUrl, {
|
let downloadResponse;
|
||||||
responseType: 'stream',
|
let retryCount = 0;
|
||||||
timeout: 120000, // 2 minutes for download
|
const maxRetries = 3;
|
||||||
headers: {
|
|
||||||
'User-Agent': 'LuHost/1.0'
|
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
|
// Create target directory
|
||||||
await fs.mkdir(targetPath, { recursive: true });
|
await fs.mkdir(targetPath, { recursive: true });
|
||||||
@@ -137,10 +168,28 @@ class ContentDBClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Extract zip file
|
// 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
|
// 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 {
|
} else {
|
||||||
// For non-zip files, save directly
|
// For non-zip files, save directly
|
||||||
const fileName = path.basename(release.url) || 'download';
|
const fileName = path.basename(release.url) || 'download';
|
||||||
@@ -173,57 +222,151 @@ class ContentDBClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
zipfile.readEntry();
|
const entries = [];
|
||||||
|
|
||||||
zipfile.on('entry', async (entry) => {
|
// First pass: collect all entries to analyze structure
|
||||||
const entryPath = path.join(targetPath, entry.fileName);
|
zipfile.on('entry', (entry) => {
|
||||||
|
entries.push(entry);
|
||||||
|
zipfile.readEntry();
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure the entry path is within target directory (security)
|
zipfile.on('end', async () => {
|
||||||
const normalizedPath = path.normalize(entryPath);
|
try {
|
||||||
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
|
// Analyze if we have a common root directory that should be stripped
|
||||||
zipfile.readEntry();
|
let commonRoot = null;
|
||||||
return;
|
let shouldStripRoot = false;
|
||||||
}
|
|
||||||
|
|
||||||
if (/\/$/.test(entry.fileName)) {
|
if (entries.length > 0) {
|
||||||
// Directory entry
|
// Find files (not directories) to analyze structure
|
||||||
try {
|
const fileEntries = entries.filter(e => !e.fileName.endsWith('/') && e.fileName.trim() !== '');
|
||||||
await fs.mkdir(normalizedPath, { recursive: true });
|
|
||||||
zipfile.readEntry();
|
if (fileEntries.length > 0) {
|
||||||
} catch (error) {
|
// Check if all files are in the same top-level directory
|
||||||
reject(error);
|
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.close();
|
||||||
zipfile.openReadStream(entry, (err, readStream) => {
|
|
||||||
if (err) {
|
// Second pass: reopen zip and extract files sequentially
|
||||||
reject(err);
|
yauzl.open(zipPath, { lazyEntries: true }, (reopenErr, newZipfile) => {
|
||||||
|
if (reopenErr) {
|
||||||
|
reject(reopenErr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
let entryIndex = 0;
|
||||||
const parentDir = path.dirname(normalizedPath);
|
|
||||||
fs.mkdir(parentDir, { recursive: true })
|
|
||||||
.then(() => {
|
|
||||||
const writeStream = require('fs').createWriteStream(normalizedPath);
|
|
||||||
readStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
const processNextEntry = () => {
|
||||||
zipfile.readEntry();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
newZipfile.on('error', (zipError) => {
|
||||||
})
|
newZipfile.close();
|
||||||
.catch(reject);
|
reject(zipError);
|
||||||
|
});
|
||||||
|
|
||||||
|
processNextEntry();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
zipfile.close();
|
||||||
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
zipfile.on('end', () => {
|
zipfile.on('error', (error) => {
|
||||||
resolve();
|
zipfile.close();
|
||||||
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
zipfile.on('error', reject);
|
zipfile.readEntry();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,20 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
class PackageRegistry {
|
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.dbPath = dbPath;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
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
|
// Ensure data directory exists
|
||||||
const dir = path.dirname(this.dbPath);
|
const dir = path.dirname(this.dbPath);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
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() {
|
async createTables() {
|
||||||
const sql = `
|
const sql = `
|
||||||
CREATE TABLE IF NOT EXISTS installed_packages (
|
CREATE TABLE IF NOT EXISTS installed_packages (
|
||||||
|
107
utils/paths.js
107
utils/paths.js
@@ -14,24 +14,104 @@ class LuantiPaths {
|
|||||||
await appConfig.load();
|
await appConfig.load();
|
||||||
const configuredDataDir = appConfig.getDataDirectory();
|
const configuredDataDir = appConfig.getDataDirectory();
|
||||||
this.setDataDirectory(configuredDataDir);
|
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() {
|
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 homeDir = os.homedir();
|
||||||
const possibleDirs = [
|
const candidateDirs = [
|
||||||
path.join(homeDir, '.luanti'),
|
// Flatpak installations
|
||||||
path.join(homeDir, '.minetest')
|
{
|
||||||
|
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
|
// Check which directories exist and contain expected Luanti files
|
||||||
for (const dir of possibleDirs) {
|
const validDirs = [];
|
||||||
if (fs.existsSync(dir)) {
|
for (const candidate of candidateDirs) {
|
||||||
return dir;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(homeDir, '.minetest');
|
// Sort by confidence (directories with more Luanti files first)
|
||||||
|
validDirs.sort((a, b) => b.confidence - a.confidence);
|
||||||
|
|
||||||
|
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) {
|
setDataDirectory(dataDir) {
|
||||||
@@ -113,12 +193,11 @@ class LuantiPaths {
|
|||||||
|
|
||||||
async getInstalledGames() {
|
async getInstalledGames() {
|
||||||
const games = [];
|
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 = [
|
const possibleGameDirs = [
|
||||||
this.gamesDir, // User games directory
|
this.gamesDir // Only the configured 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
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const gameDir of possibleGameDirs) {
|
for (const gameDir of possibleGameDirs) {
|
||||||
|
@@ -22,6 +22,7 @@ class ServerManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
this.debugFileWatcher = null;
|
this.debugFileWatcher = null;
|
||||||
this.lastDebugFilePosition = 0;
|
this.lastDebugFilePosition = 0;
|
||||||
|
this.lastStopTime = 0; // Track when we last stopped a server
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerStatus() {
|
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
|
// Only check for external servers if we truly have no server process at all
|
||||||
if (!this.isRunning) {
|
// 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();
|
const externalServer = await this.detectExternalLuantiServer();
|
||||||
if (externalServer) {
|
if (externalServer) {
|
||||||
|
console.log(`ServerManager: Detected truly external server (PID: ${externalServer.pid})`);
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
this.startTime = externalServer.startTime;
|
this.startTime = externalServer.startTime;
|
||||||
@@ -142,9 +150,9 @@ class ServerManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if minetest/luanti executable exists
|
// 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,
|
cwd: paths.minetestDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
@@ -206,35 +214,189 @@ class ServerManager extends EventEmitter {
|
|||||||
throw new Error('Server is not running');
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
let timeoutHandle = null;
|
||||||
if (this.serverProcess && this.isRunning) {
|
let resolved = false;
|
||||||
// Force kill if graceful shutdown failed
|
|
||||||
this.serverProcess.kill('SIGKILL');
|
const cleanup = (message) => {
|
||||||
resolve({ success: true, message: 'Server force-stopped' });
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
}
|
}
|
||||||
}, 10000); // 10 second timeout
|
|
||||||
|
|
||||||
this.serverProcess.on('exit', () => {
|
// Clean up state immediately
|
||||||
clearTimeout(timeout);
|
this.cleanupServerState();
|
||||||
resolve({ success: true, message: 'Server stopped gracefully' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try graceful shutdown first
|
// Add a delay to prevent race conditions with external detection
|
||||||
if (force) {
|
setTimeout(() => {
|
||||||
this.serverProcess.kill('SIGTERM');
|
resolve({ success: true, message });
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
// Send shutdown command to server
|
// Try graceful shutdown first for managed servers
|
||||||
try {
|
try {
|
||||||
this.serverProcess.stdin.write('/shutdown\n');
|
this.serverProcess.stdin.write('/shutdown\n');
|
||||||
} catch (error) {
|
console.log('ServerManager: Sent shutdown command to server');
|
||||||
// If stdin fails, use SIGTERM
|
} catch (stdinError) {
|
||||||
this.serverProcess.kill('SIGTERM');
|
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) {
|
async restartServer(worldName = null) {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
await this.stopServer();
|
await this.stopServer();
|
||||||
@@ -262,66 +424,153 @@ class ServerManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findMinetestExecutable() {
|
async findMinetestExecutable() {
|
||||||
// Whitelist of allowed executable names to prevent command injection
|
// Check different installation methods for Luanti
|
||||||
const allowedExecutables = ['luanti', 'minetest', 'minetestserver'];
|
const installationMethods = [
|
||||||
const foundExecutables = [];
|
// 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 {
|
try {
|
||||||
// Validate executable name against whitelist
|
// Test if the command exists and works
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
const testResult = await new Promise((resolve, reject) => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const execPath = await new Promise((resolve, reject) => {
|
|
||||||
// Use spawn instead of exec to avoid command injection
|
|
||||||
const { spawn } = require('child_process');
|
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 stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
whichProcess.stdout.on('data', (data) => {
|
testProcess.stdout.on('data', (data) => {
|
||||||
stdout += data.toString();
|
stdout += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
whichProcess.stderr.on('data', (data) => {
|
testProcess.stderr.on('data', (data) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
whichProcess.on('close', (code) => {
|
testProcess.on('close', (code) => {
|
||||||
if (code === 0) {
|
if ((code === 0 || code === null) && (stdout.includes('Luanti') || stdout.includes('Minetest'))) {
|
||||||
resolve(stdout.trim());
|
resolve({ command: method.command, args: method.args, output: stdout });
|
||||||
} else {
|
} 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);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate that the returned path is safe
|
const executableInfo = {
|
||||||
if (execPath && path.isAbsolute(execPath)) {
|
command: testResult.command,
|
||||||
foundExecutables.push({ name, path: execPath });
|
args: testResult.args,
|
||||||
this.addLogLine('info', `Found executable: ${name} at ${execPath}`);
|
fullCommand: testResult.args.length > 0 ? `${testResult.command} ${testResult.args.join(' ')}` : testResult.command
|
||||||
return execPath; // Return the full path for security
|
};
|
||||||
}
|
|
||||||
|
this.addLogLine('info', `Found Luanti: ${executableInfo.fullCommand}`);
|
||||||
|
return executableInfo;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue to next possibility
|
// Continue to next method
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide detailed error message
|
// Provide detailed error message with setup instructions
|
||||||
const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` +
|
const errorMsg = `Luanti/Minetest not found or not working properly.\n\n` +
|
||||||
`Searched for: ${allowedExecutables.join(', ')}\n` +
|
`Please install Luanti using one of these methods:\n` +
|
||||||
`Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`;
|
`• 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);
|
this.addLogLine('error', errorMsg);
|
||||||
throw new 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) {
|
addLogLine(type, content) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const logEntry = {
|
const logEntry = {
|
||||||
@@ -666,7 +915,6 @@ class ServerManager extends EventEmitter {
|
|||||||
const playerData = new Map(); // Map to store player name -> player info
|
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)
|
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()) {
|
for (const line of lines.reverse()) {
|
||||||
// Parse timestamp from log line
|
// Parse timestamp from log line
|
||||||
@@ -688,7 +936,6 @@ class ServerManager extends EventEmitter {
|
|||||||
const playerName = match[1];
|
const playerName = match[1];
|
||||||
const actionDescription = match[2] || type;
|
const actionDescription = match[2] || type;
|
||||||
|
|
||||||
console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription);
|
|
||||||
|
|
||||||
// Filter out obvious non-player names and false positives
|
// Filter out obvious non-player names and false positives
|
||||||
if (!playerName.includes('Entity') &&
|
if (!playerName.includes('Entity') &&
|
||||||
@@ -701,7 +948,6 @@ class ServerManager extends EventEmitter {
|
|||||||
playerName.length < 20 &&
|
playerName.length < 20 &&
|
||||||
/^[a-zA-Z0-9_]+$/.test(playerName)) {
|
/^[a-zA-Z0-9_]+$/.test(playerName)) {
|
||||||
|
|
||||||
console.log('DEBUG: Player passed filters:', playerName);
|
|
||||||
|
|
||||||
// Update player data with most recent activity
|
// Update player data with most recent activity
|
||||||
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
|
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
|
||||||
@@ -736,7 +982,6 @@ class ServerManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -67,6 +67,53 @@ const body = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${isFirstUser ? `
|
||||||
|
<div class="form-group" style="margin-top: 2rem;">
|
||||||
|
<label for="dataDirectory">Luanti Data Directory*</label>
|
||||||
|
<select id="dataDirectory" name="dataDirectory" class="form-control" required>
|
||||||
|
${detectedDirectories.length > 0 ?
|
||||||
|
detectedDirectories.map(dir => `
|
||||||
|
<option value="${dir.path}"
|
||||||
|
${dir.path === defaultDataDir ? 'selected' : ''}
|
||||||
|
data-confidence="${dir.confidence}">
|
||||||
|
${dir.type}: ${dir.path}
|
||||||
|
${dir.hasConfig ? ' ✓ Config' : ''}
|
||||||
|
${dir.hasWorlds ? ' ✓ Worlds' : ''}
|
||||||
|
${dir.hasDebug ? ' ✓ Active' : ''}
|
||||||
|
</option>
|
||||||
|
`).join('') :
|
||||||
|
`<option value="${defaultDataDir}">${defaultDataDir} (Default)</option>`
|
||||||
|
}
|
||||||
|
<option value="custom">Custom directory...</option>
|
||||||
|
</select>
|
||||||
|
<small style="color: var(--text-secondary);">
|
||||||
|
Choose the correct Luanti data directory based on your installation method.
|
||||||
|
Directories with ✓ marks have existing Luanti files.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div id="customDirGroup" style="display: none; margin-top: 1rem;">
|
||||||
|
<label for="customDataDirectory">Custom Directory Path</label>
|
||||||
|
<input type="text"
|
||||||
|
id="customDataDirectory"
|
||||||
|
name="customDataDirectory"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="/path/to/luanti/data/directory">
|
||||||
|
<small style="color: var(--text-secondary);">Enter the full path to your Luanti data directory</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h4 style="margin-top: 0;">⚠️ Important: Data Directory Selection</h4>
|
||||||
|
<p>The data directory must match where your Luanti installation stores its data:</p>
|
||||||
|
<ul style="margin: 0.5rem 0;">
|
||||||
|
<li><strong>Flatpak:</strong> ~/.var/app/org.luanti.luanti/.minetest</li>
|
||||||
|
<li><strong>System Package:</strong> ~/.minetest or ~/.luanti</li>
|
||||||
|
<li><strong>Snap:</strong> ~/snap/luanti/current/.local/share/minetest</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-bottom: 0;">Choosing the wrong directory will prevent the web interface from managing your worlds and server properly.</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
||||||
<a href="/login" class="btn btn-outline">
|
<a href="/login" class="btn btn-outline">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
@@ -109,6 +156,24 @@ document.getElementById('password').addEventListener('input', function() {
|
|||||||
confirmPassword.dispatchEvent(new Event('input'));
|
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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
%>
|
%>
|
||||||
|
@@ -279,13 +279,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showStatus(result.message + ' ✅', 'success', false);
|
showStatus(result.message + ' ✅ Redirecting to Extensions...', 'success', false);
|
||||||
clearForm();
|
clearForm();
|
||||||
|
|
||||||
// Auto-hide success message after 5 seconds
|
// Redirect to extensions page after 2 seconds to show the newly installed package
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
installStatus.style.display = 'none';
|
window.location.href = '/extensions';
|
||||||
}, 5000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showStatus(result.error || 'Installation failed', 'error', false);
|
showStatus(result.error || 'Installation failed', 'error', false);
|
||||||
}
|
}
|
||||||
|
@@ -22,7 +22,7 @@ const body = `
|
|||||||
<div class="stat-value" style="font-size: 1rem; word-break: break-all;">
|
<div class="stat-value" style="font-size: 1rem; word-break: break-all;">
|
||||||
${stats.minetestDir}
|
${stats.minetestDir}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-label">Minetest Directory</div>
|
<div class="stat-label">Data Directory</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ const body = `
|
|||||||
<td>${systemInfo.nodeVersion}</td>
|
<td>${systemInfo.nodeVersion}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Minetest Directory</strong></td>
|
<td><strong>Data Directory</strong></td>
|
||||||
<td style="word-break: break-all;">${stats.minetestDir}</td>
|
<td style="word-break: break-all;">${stats.minetestDir}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@@ -31,50 +31,17 @@ const body = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Install Card -->
|
<!-- Browse ContentDB Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-body text-center" style="padding: 2rem;">
|
||||||
<h4>⚡ Quick Install</h4>
|
<div style="font-size: 3rem; margin-bottom: 1rem;">🌐</div>
|
||||||
</div>
|
<h4 style="margin-bottom: 1rem;">Browse ContentDB</h4>
|
||||||
<div class="card-body">
|
<p style="color: var(--text-secondary); margin-bottom: 1.5rem; font-size: 0.9rem;">
|
||||||
<form id="quickInstallForm">
|
Discover and install thousands of games, mods, and texture packs from the Luanti community.
|
||||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
</p>
|
||||||
<div class="form-group mb-3">
|
<a href="/contentdb" class="btn btn-primary btn-lg" style="padding: 1rem 2rem; font-size: 1.1rem;">
|
||||||
<label for="quickPackageUrl">Package URL or Author/Name:</label>
|
🚀 Explore ContentDB
|
||||||
<input type="text" id="quickPackageUrl" name="packageUrl" class="form-control"
|
</a>
|
||||||
placeholder="e.g., mesecons or author/name" required>
|
|
||||||
<div id="quickUrlValidation"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mb-3" id="quickLocationGroup">
|
|
||||||
<label for="quickInstallLocation">Install Location:</label>
|
|
||||||
<select id="quickInstallLocation" name="installLocation" class="form-control">
|
|
||||||
<option value="global">Global</option>
|
|
||||||
<option value="world">Specific World</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="quickWorldName" name="worldName" class="form-control mt-2" style="display: none;">
|
|
||||||
<option value="">Select a world...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="installDeps" value="on">
|
|
||||||
Install Dependencies
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="quickInstallBtn" class="btn btn-primary btn-block">
|
|
||||||
📦 Install
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="quickInstallStatus" style="display: none;">
|
|
||||||
<div id="quickStatusAlert" class="alert mt-2">
|
|
||||||
<span id="quickStatusMessage"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,231 +398,6 @@ const body = `
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const quickInstallForm = document.getElementById('quickInstallForm');
|
|
||||||
const quickPackageUrlInput = document.getElementById('quickPackageUrl');
|
|
||||||
const quickInstallLocationSelect = document.getElementById('quickInstallLocation');
|
|
||||||
const quickWorldNameSelect = document.getElementById('quickWorldName');
|
|
||||||
const quickLocationGroup = document.getElementById('quickLocationGroup');
|
|
||||||
const quickInstallBtn = document.getElementById('quickInstallBtn');
|
|
||||||
const quickInstallStatus = document.getElementById('quickInstallStatus');
|
|
||||||
const quickUrlValidation = document.getElementById('quickUrlValidation');
|
|
||||||
|
|
||||||
// Load available worlds
|
|
||||||
loadWorlds();
|
|
||||||
|
|
||||||
// Show/hide world selection
|
|
||||||
quickInstallLocationSelect.addEventListener('change', function() {
|
|
||||||
if (this.value === 'world') {
|
|
||||||
quickWorldNameSelect.style.display = 'block';
|
|
||||||
quickWorldNameSelect.required = true;
|
|
||||||
} else {
|
|
||||||
quickWorldNameSelect.style.display = 'none';
|
|
||||||
quickWorldNameSelect.required = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// URL validation
|
|
||||||
let validationTimeout;
|
|
||||||
quickPackageUrlInput.addEventListener('input', function() {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
validationTimeout = setTimeout(() => {
|
|
||||||
validateUrl(this.value);
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quick install form submission
|
|
||||||
quickInstallForm.addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const url = quickPackageUrlInput.value.trim();
|
|
||||||
if (!url) {
|
|
||||||
showQuickStatus('Please enter a package URL', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
quickInstallBtn.disabled = true;
|
|
||||||
quickInstallBtn.textContent = '⏳ Installing...';
|
|
||||||
showQuickStatus('Installing package...', 'info');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
params.append(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/extensions/install-url', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: params
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showQuickStatus(result.message + ' ✅', 'success');
|
|
||||||
quickInstallForm.reset();
|
|
||||||
quickWorldNameSelect.style.display = 'none';
|
|
||||||
quickWorldNameSelect.required = false;
|
|
||||||
|
|
||||||
// Reload page after 2 seconds to show new extension
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Handle specific validation errors with better messaging
|
|
||||||
if (result.type === 'invalid_installation_target' && result.packageType === 'game') {
|
|
||||||
showQuickStatus('❌ ' + result.error, 'warning');
|
|
||||||
} else {
|
|
||||||
showQuickStatus(result.error || 'Installation failed', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Installation error:', error);
|
|
||||||
showQuickStatus('Installation failed: ' + error.message, 'danger');
|
|
||||||
} finally {
|
|
||||||
quickInstallBtn.disabled = false;
|
|
||||||
quickInstallBtn.textContent = '📦 Install';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadWorlds() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/worlds');
|
|
||||||
const worlds = await response.json();
|
|
||||||
|
|
||||||
quickWorldNameSelect.innerHTML = '<option value="">Select a world...</option>';
|
|
||||||
worlds.forEach(world => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = world.name;
|
|
||||||
option.textContent = world.displayName || world.name;
|
|
||||||
quickWorldNameSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load worlds:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateUrl(url) {
|
|
||||||
if (!url.trim()) {
|
|
||||||
quickUrlValidation.innerHTML = '';
|
|
||||||
resetLocationOptions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseContentDBUrl(url);
|
|
||||||
|
|
||||||
if (parsed.author && parsed.name) {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-info">🔄 Checking package...</small>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check package type via API
|
|
||||||
const response = await fetch('/api/contentdb/package-info', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ author: parsed.author, name: parsed.name })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const packageInfo = await response.json();
|
|
||||||
const packageType = packageInfo.type || 'mod';
|
|
||||||
|
|
||||||
if (packageType === 'game') {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Game: ' + parsed.author + '/' + parsed.name + '</small>';
|
|
||||||
restrictLocationOptionsForGame();
|
|
||||||
} else {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ ' + packageType.charAt(0).toUpperCase() + packageType.slice(1) + ': ' + parsed.author + '/' + parsed.name + '</small>';
|
|
||||||
resetLocationOptions();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
|
|
||||||
resetLocationOptions();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
|
|
||||||
resetLocationOptions();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quickUrlValidation.innerHTML = '<small class="text-danger">❌ Invalid URL format</small>';
|
|
||||||
resetLocationOptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restrictLocationOptionsForGame() {
|
|
||||||
// For games, only allow global installation
|
|
||||||
quickInstallLocationSelect.innerHTML = '<option value="global">Global (Games are shared across all worlds)</option>';
|
|
||||||
quickInstallLocationSelect.disabled = true;
|
|
||||||
quickWorldNameSelect.style.display = 'none';
|
|
||||||
quickWorldNameSelect.required = false;
|
|
||||||
|
|
||||||
// Add explanation
|
|
||||||
const existingWarning = document.getElementById('game-warning');
|
|
||||||
if (!existingWarning) {
|
|
||||||
const warning = document.createElement('div');
|
|
||||||
warning.id = 'game-warning';
|
|
||||||
warning.className = 'alert alert-info mt-2';
|
|
||||||
warning.innerHTML = '<small><strong>ℹ️ Note:</strong> Games are installed globally and shared across all worlds. To use this game, create a new world and select it during world creation.</small>';
|
|
||||||
quickLocationGroup.appendChild(warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLocationOptions() {
|
|
||||||
// Reset to normal options
|
|
||||||
quickInstallLocationSelect.innerHTML =
|
|
||||||
'<option value="global">Global</option>' +
|
|
||||||
'<option value="world">Specific World</option>';
|
|
||||||
quickInstallLocationSelect.disabled = false;
|
|
||||||
|
|
||||||
// Remove warning if it exists
|
|
||||||
const warning = document.getElementById('game-warning');
|
|
||||||
if (warning) {
|
|
||||||
warning.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset world selection based on current value
|
|
||||||
if (quickInstallLocationSelect.value === 'world') {
|
|
||||||
quickWorldNameSelect.style.display = 'block';
|
|
||||||
quickWorldNameSelect.required = true;
|
|
||||||
} else {
|
|
||||||
quickWorldNameSelect.style.display = 'none';
|
|
||||||
quickWorldNameSelect.required = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseContentDBUrl(url) {
|
|
||||||
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
|
|
||||||
|
|
||||||
const patterns = [
|
|
||||||
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
|
|
||||||
/^([^/]+)\\/([^/]+)$/
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = url.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
return { author: match[1], name: match[2] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { author: null, name: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function showQuickStatus(message, type) {
|
|
||||||
const statusAlert = document.getElementById('quickStatusAlert');
|
|
||||||
const statusMessage = document.getElementById('quickStatusMessage');
|
|
||||||
|
|
||||||
const alertClass = 'alert-' + type;
|
|
||||||
statusAlert.className = 'alert mt-2 ' + alertClass;
|
|
||||||
statusMessage.textContent = message;
|
|
||||||
quickInstallStatus.style.display = 'block';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function filterExtensions(type) {
|
function filterExtensions(type) {
|
||||||
const cards = document.querySelectorAll('.extension-card');
|
const cards = document.querySelectorAll('.extension-card');
|
||||||
const tabs = document.querySelectorAll('.tab-btn');
|
const tabs = document.querySelectorAll('.tab-btn');
|
||||||
|
Reference in New Issue
Block a user