## Major Features Added
### Configuration-Based Mod Management
- Implement proper Luanti mod system using load_mod_* entries in world.mt
- Add mod enable/disable via configuration instead of file copying
- Support both global mods (config-enabled) and world mods (physically installed)
- Clear UI distinction with badges: "Global (Enabled)", "World Copy", "Missing"
- Automatic registry verification to sync database with filesystem state
### Game ID Alias System
- Fix minetest_game/minetest technical debt with proper alias mapping
- Map minetest_game → minetest for world.mt files (matches Luanti internal behavior)
- Reference: c9d4c33174/src/content/subgames.cpp (L21)
### Navigation Improvements
- Fix navigation menu spacing and text overflow issues
- Change "Configuration" to "Config" for better fit
- Implement responsive font sizing with clamp() for better scaling
- Even distribution of nav buttons across full width
### Package Registry Enhancements
- Add verifyAndCleanRegistry() to automatically remove stale package entries
- Periodic verification (every 5 minutes) to keep registry in sync with filesystem
- Fix "already installed" errors for manually deleted packages
- Integration across dashboard, ContentDB, and installation workflows
## Technical Improvements
### Mod System Architecture
- Enhanced ConfigParser to handle load_mod_* entries in world.mt files
- Support for both configuration-based and file-based mod installations
- Proper mod type detection and management workflows
- Updated world details to show comprehensive mod information
### UI/UX Enhancements
- Responsive navigation with proper text scaling
- Improved mod management interface with clear action buttons
- Better visual hierarchy and status indicators
- Enhanced error handling and user feedback
### Code Quality
- Clean up gitignore to properly exclude runtime files
- Add package-lock.json for consistent dependency management
- Remove excess runtime database and log files
- Add .claude/ directory to gitignore
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
301 lines
8.5 KiB
JavaScript
301 lines
8.5 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();
|
|
|
|
// Verify and clean package registry
|
|
try {
|
|
const packageRegistry = require('./utils/package-registry');
|
|
await packageRegistry.verifyIfNeeded();
|
|
} catch (registryError) {
|
|
console.warn('Registry verification failed on dashboard load:', registryError);
|
|
}
|
|
|
|
// 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 }; |