Files
LuHost/routes/worlds.js
Nathan Schneider 2d3b1166fe 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>
2025-08-24 19:17:38 -06:00

327 lines
9.8 KiB
JavaScript

const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
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();
// Worlds listing page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
let worlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
try {
const stats = await fs.stat(worldPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseWorldConfig(configPath);
let playerCount = 0;
try {
const playersDbPath = path.join(worldPath, 'players.sqlite');
const db = new sqlite3.Database(playersDbPath);
const all = promisify(db.all.bind(db));
const result = await all('SELECT COUNT(*) as count FROM players');
playerCount = result[0]?.count || 0;
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: gameid,
gameTitle: gameTitle,
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
playerCount,
lastModified: stats.mtime,
size: stats.size
});
} catch (worldError) {
console.error(`Error reading world ${worldDir}:`, worldError);
}
}
} catch (dirError) {}
res.render('worlds/index', {
title: 'Worlds',
worlds: worlds,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting worlds:', error);
res.status(500).render('error', {
error: 'Failed to load worlds',
message: error.message
});
}
});
// New world page
router.get('/new', async (req, res) => {
try {
const games = await paths.getInstalledGames();
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: games
});
} catch (error) {
console.error('Error getting games for new world:', error);
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: [
{ name: 'minetest_game', title: 'Minetest Game (Default)', description: '' },
{ name: 'minimal', title: 'Minimal', description: '' }
],
error: 'Could not load installed games, showing defaults only.'
});
}
});
// Create world
router.post('/create', async (req, res) => {
console.log('=== WORLD CREATION STARTED ===');
console.log('Request body:', req.body);
try {
const { name, gameid } = req.body;
console.log('Extracted name:', name, 'gameid:', gameid);
if (!paths.isValidWorldName(name)) {
return res.status(400).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Invalid world name. Only letters, numbers, underscore and hyphen allowed.',
formData: req.body
});
}
const worldPath = paths.getWorldPath(name);
try {
await fs.access(worldPath);
return res.status(409).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'World already exists',
formData: req.body
});
} catch {}
console.log('Starting world creation for:', name, 'with gameid:', gameid);
// 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
});
}
});
// Respond immediately with a "creating" status
res.redirect('/worlds?creating=' + encodeURIComponent(name));
} catch (error) {
console.error('Error starting world creation:', error);
res.status(500).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Failed to start world creation: ' + error.message,
formData: req.body
});
}
});
// World details page
router.get('/:worldName', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).render('error', {
error: 'Invalid world name'
});
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).render('error', {
error: 'World not found'
});
}
const config = await ConfigParser.parseWorldConfig(configPath);
const stats = await fs.stat(worldPath);
let worldSize = 0;
try {
const mapDbPath = path.join(worldPath, 'map.sqlite');
const mapStats = await fs.stat(mapDbPath);
worldSize = mapStats.size;
} catch {}
let enabledMods = [];
try {
const worldModsPath = paths.getWorldModsPath(worldName);
const modDirs = await fs.readdir(worldModsPath);
for (const modDir of modDirs) {
const modConfigPath = path.join(worldModsPath, modDir, 'mod.conf');
try {
const modConfig = await ConfigParser.parseModConfig(modConfigPath);
enabledMods.push({
name: modDir,
title: modConfig.title || modDir,
description: modConfig.description || '',
author: modConfig.author || '',
location: 'world'
});
} catch {}
}
} catch {}
const worldDetails = {
name: worldName,
displayName: config.server_name || worldName,
description: config.server_description || '',
gameid: config.gameid || 'minetest_game',
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
serverAnnounce: config.server_announce || false,
worldSize,
created: stats.birthtime,
lastModified: stats.mtime,
enabledMods,
config: config
};
res.render('worlds/details', {
title: `World: ${worldDetails.displayName}`,
world: worldDetails,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting world details:', error);
res.status(500).render('error', {
error: 'Failed to load world details',
message: error.message
});
}
});
// Update world
router.post('/:worldName/update', async (req, res) => {
try {
const { worldName } = req.params;
const updates = req.body;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
const currentConfig = await ConfigParser.parseWorldConfig(configPath);
// Convert form data
const updatedConfig = {
...currentConfig,
server_name: updates.displayName || currentConfig.server_name,
server_description: updates.description || currentConfig.server_description,
creative_mode: updates.creativeMode === 'on',
enable_damage: updates.enableDamage !== 'off',
enable_pvp: updates.enablePvp !== 'off',
server_announce: updates.serverAnnounce === 'on'
};
await ConfigParser.writeWorldConfig(configPath, updatedConfig);
res.redirect(`/worlds/${worldName}?updated=true`);
} catch (error) {
console.error('Error updating world:', error);
res.status(500).json({ error: 'Failed to update world' });
}
});
// Delete world
router.post('/:worldName/delete', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
// Deletion confirmed by frontend confirmation dialog
await fs.rm(worldPath, { recursive: true, force: true });
res.redirect('/worlds?deleted=' + encodeURIComponent(worldName));
} catch (error) {
console.error('Error deleting world:', error);
res.status(500).json({ error: 'Failed to delete world' });
}
});
module.exports = router;