Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
411
routes/worlds.js
Normal file
411
routes/worlds.js
Normal file
@@ -0,0 +1,411 @@
|
||||
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 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) {}
|
||||
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: config.server_name || worldDir,
|
||||
description: config.server_description || '',
|
||||
gameid: config.gameid || 'minetest_game',
|
||||
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);
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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));
|
||||
} catch (error) {
|
||||
console.error('Error creating world:', error);
|
||||
res.status(500).render('worlds/new', {
|
||||
title: 'Create World',
|
||||
currentPage: 'worlds',
|
||||
error: 'Failed to create world: ' + 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;
|
Reference in New Issue
Block a user