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); // 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 };