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>
290 lines
8.2 KiB
JavaScript
290 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);
|
|
|
|
// 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 }; |