Files
LuHost/app.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

293 lines
8.2 KiB
JavaScript

const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const path = require('path');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const csrf = require('csurf');
const { spawn } = require('child_process');
const chokidar = require('chokidar');
const os = require('os');
// Import utilities
const paths = require('./utils/paths');
const ConfigParser = require('./utils/config-parser');
const ContentDBClient = require('./utils/contentdb');
const serverManager = require('./utils/shared-server-manager');
// Import middleware
const { requireAuth, attachUser } = require('./middleware/auth');
const { validateInput, xssProtection, additionalSecurityHeaders, validateRequestSize, pathTraversalProtection } = require('./middleware/security');
// Import routes
const authRouter = require('./routes/auth');
const usersRouter = require('./routes/users');
const worldsRouter = require('./routes/worlds');
const modsRouter = require('./routes/mods');
const serverRouter = require('./routes/server');
const configRouter = require('./routes/config');
const contentdbRouter = require('./routes/contentdb');
const extensionsRouter = require('./routes/extensions');
const { router: apiRouter, setSocketIO } = require('./routes/api');
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
const PORT = process.env.PORT || 3000;
// Global server state
let serverProcess = null;
let serverStatus = 'stopped';
let logWatcher = null;
// Security and performance middleware
app.use(additionalSecurityHeaders);
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for now
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for now
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "ws:", "wss:"],
formAction: ["'self'"],
frameAncestors: ["'none'"]
}
}
}));
app.use(compression());
app.use(validateRequestSize);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000 // limit each IP to 1000 requests per windowMs (increased for testing)
});
app.use(limiter);
// Session middleware
app.use(session({
store: new SQLiteStore({
db: 'sessions.db',
dir: '.'
}),
secret: process.env.SESSION_SECRET || 'luanti-server-manager-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Security middleware
app.use(pathTraversalProtection);
app.use(validateInput);
app.use(xssProtection);
// Authentication middleware
app.use(attachUser);
// CSRF protection middleware (only for non-API routes)
const csrfProtection = csrf();
app.use((req, res, next) => {
// Skip CSRF for API routes and auth endpoints during setup
if (req.path.startsWith('/api/') ||
req.path.startsWith('/health') ||
(req.path === '/login' && req.method === 'POST') ||
(req.path === '/register' && req.method === 'POST')) {
return next();
}
return csrfProtection(req, res, next);
});
// Make CSRF token and security functions available to all templates
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken ? req.csrfToken() : null;
res.locals.escapeHtml = require('./middleware/security').escapeHtml;
next();
});
// Static files
app.use('/static', express.static(path.join(__dirname, 'public')));
// Template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Make utility functions available to templates
app.locals.formatDate = (date) => new Date(date).toLocaleString();
app.locals.formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
app.locals.formatUptime = (uptime) => {
if (!uptime) return 'N/A';
const seconds = Math.floor(uptime / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
};
// Initialize API with Socket.IO
setSocketIO(io);
// Make Socket.IO available to routes
app.set('socketio', io);
// Socket.IO connection handling
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Send current server status
socket.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
// Broadcast server status updates
function broadcastServerStatus() {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
}
// Broadcast log messages
function broadcastLog(logEntry) {
io.emit('serverLog', logEntry);
}
// Authentication routes (public)
app.use('/', authRouter);
// API routes (require authentication)
app.use('/api', requireAuth, apiRouter);
// Protected routes (require authentication)
app.use('/users', requireAuth, usersRouter);
app.use('/worlds', requireAuth, worldsRouter);
app.use('/mods', requireAuth, modsRouter);
app.use('/server', requireAuth, serverRouter);
app.use('/config', requireAuth, configRouter);
app.use('/contentdb', requireAuth, contentdbRouter);
app.use('/extensions', requireAuth, extensionsRouter);
// Main dashboard route (protected)
app.get('/', requireAuth, async (req, res) => {
try {
paths.ensureDirectories();
// Get basic stats for dashboard
const fs = require('fs').promises;
let worldCount = 0;
let modCount = 0;
try {
const worldDirs = await fs.readdir(paths.worldsDir);
worldCount = worldDirs.length;
} catch {}
try {
const modDirs = await fs.readdir(paths.modsDir);
modCount = modDirs.length;
} catch {}
const stats = {
worlds: worldCount,
mods: modCount,
minetestDir: paths.minetestDir
};
const systemInfo = {
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version
};
res.render('dashboard', {
title: 'LuHost Dashboard',
stats: stats,
systemInfo: systemInfo
});
} catch (error) {
console.error('Error loading dashboard:', error);
res.status(500).render('error', {
error: 'Failed to load dashboard',
message: error.message
});
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
minetestDir: paths.minetestDir,
serverStatus: serverStatus
});
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('error', {
error: 'Something went wrong!',
message: err.message
});
});
// 404 handler
app.use((req, res) => {
res.status(404).render('error', {
error: 'Page not found',
message: `The page ${req.url} does not exist.`
});
});
// Server startup
server.listen(PORT, async () => {
console.log(`LuHost Server running on http://localhost:${PORT}`);
// Initialize paths with configuration
try {
await paths.initialize();
console.log(`Luanti data directory: ${paths.minetestDir}`);
} catch (error) {
console.error('Failed to initialize paths:', error);
console.log(`Using default Luanti directory: ${paths.minetestDir}`);
}
// Ensure minetest directories exist
paths.ensureDirectories();
});
// Export for potential testing
module.exports = { app, server, io };