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:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Node.js dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Session storage
|
||||
sessions.db
|
||||
users.db
|
||||
|
||||
# Application data
|
||||
data/
|
||||
*.log
|
||||
security.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Runtime files
|
||||
*.pid
|
||||
server.log*
|
||||
debug.txt
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
*~
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Luanti/Minetest specific files (if running locally)
|
||||
worlds/
|
||||
mods/
|
||||
games/
|
||||
textures/
|
||||
sounds/
|
||||
minetest.conf
|
||||
debug.txt
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
*.bak*
|
||||
backups/
|
||||
|
||||
# Certificate files (if using HTTPS)
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Security logs
|
||||
security.log*
|
||||
|
||||
# Application logs
|
||||
luhost.log*
|
||||
hostblock.log*
|
||||
app.log*
|
||||
error.log*
|
||||
access.log*
|
||||
|
||||
# PM2 logs
|
||||
logs/
|
||||
pids/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 University of Colorado Boulder. A project of the Media Enterprise Design Lab (colorado.edu/lab/medlab/).
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
356
README.md
Normal file
356
README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# LuHost
|
||||
|
||||
A modern web interface for Luanti (formerly Minetest) server management with ContentDB integration.
|
||||
|
||||
This is a project of the [Media Economies Design Lab](https://medlab.host) at the University of Colorado Boulder. It is part of our ongoing work on the governance of online community spaces. Built largely with Claude Code.
|
||||
|
||||
## Overview
|
||||
|
||||
LuHost provides a comprehensive web-based dashboard for managing Luanti servers. It features real-time server monitoring, world management, mod installation via ContentDB, and extensive configuration management - all through an intuitive web interface.
|
||||
|
||||
## Features
|
||||
|
||||
### 🖥️ Server Management
|
||||
- **Real-time monitoring** - Live server status, player count, and performance metrics
|
||||
- **External server detection** - Automatically detects and monitors external Luanti servers
|
||||
- **Process control** - Start, stop, and restart your Luanti server with one click
|
||||
- **Live console** - View server logs in real-time and send commands directly
|
||||
- **Player management** - View online players with activity tracking and kick functionality
|
||||
- **World selection** - Choose which world to run when starting the server
|
||||
|
||||
### 🌍 World Management
|
||||
- **World browser** - View and manage all your Luanti worlds
|
||||
- **Backup creation** - One-click world backups with automatic compression
|
||||
- **World deletion** - Safe world removal with confirmation dialogs
|
||||
- **World statistics** - View world size, modification dates, and details
|
||||
|
||||
### 🧩 Extensions Management
|
||||
- **ContentDB integration** - Browse and install mods and games directly from ContentDB
|
||||
- **Extensions browser** - View installed mods and games with descriptions and metadata
|
||||
- **Dependency handling** - Automatic resolution of mod dependencies
|
||||
- **Bulk operations** - Enable, disable, or remove multiple extensions at once
|
||||
- **Game management** - Install and manage different Luanti games
|
||||
- **Quick install** - Install popular extensions with one click
|
||||
|
||||
### ⚙️ Configuration Management
|
||||
- **Visual config editor** - Edit minetest.conf through an intuitive interface
|
||||
- **Sectioned settings** - Organized into Server, World, Performance, Security, Network, and Advanced categories
|
||||
- **Real-time validation** - Input validation with helpful error messages
|
||||
- **Backup system** - Automatic configuration backups before changes
|
||||
- **Raw config access** - View and download the raw configuration file
|
||||
|
||||
### 👥 User Management
|
||||
- **Multi-user support** - Create and manage multiple admin accounts
|
||||
- **Session management** - Secure authentication with session-based login
|
||||
- **Role-based access** - Granular permissions for different user roles
|
||||
|
||||
### 🔒 Security
|
||||
- **Authentication required** - All management features require login
|
||||
- **Rate limiting** - Built-in protection against abuse
|
||||
- **Input validation** - Comprehensive validation of all user inputs
|
||||
- **Secure sessions** - HTTPOnly cookies with configurable security settings
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Express.js Backend** - Robust Node.js server with EJS templating
|
||||
- **Real-time Features** - Socket.IO for live updates and monitoring
|
||||
- **Modern UI** - Responsive design with blocky Luanti-inspired theming
|
||||
- **Security** - Comprehensive input validation, authentication, and rate limiting
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** (v16 or higher)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Luanti/Minetest** installed on your system
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/your-org/luanti-webserver.git
|
||||
cd luanti-webserver
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **Access the web interface**
|
||||
Open your browser and navigate to `http://localhost:3000`
|
||||
|
||||
5. **Initial setup**
|
||||
- Create your admin account on first visit
|
||||
- Configure your Luanti installation path if needed
|
||||
- Start managing your server!
|
||||
|
||||
### Verification
|
||||
|
||||
To verify your installation is working correctly:
|
||||
|
||||
1. **Check server status** - Visit the dashboard and confirm server detection
|
||||
2. **Test external server detection** - If you have a Luanti server running, it should appear as "Running (External - Monitor Only)"
|
||||
3. **Create a test world** - Use the Worlds section to create a new world
|
||||
4. **Start managed server** - Try starting a server through LuHost with full control capabilities
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production use, consider these additional steps:
|
||||
|
||||
1. **Set environment variables**
|
||||
```bash
|
||||
export NODE_ENV=production
|
||||
export SESSION_SECRET=your-secure-random-secret
|
||||
export PORT=3000
|
||||
```
|
||||
|
||||
2. **Use a process manager**
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start app.js --name luhost
|
||||
```
|
||||
|
||||
3. **Set up reverse proxy** (optional)
|
||||
Configure nginx or Apache to proxy requests to LuHost
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `NODE_ENV` - Set to 'production' for production deployments
|
||||
- `PORT` - Port number for the web server (default: 3000)
|
||||
- `SESSION_SECRET` - Secret key for session encryption
|
||||
- `HTTPS` - Set to 'true' if using HTTPS in production
|
||||
|
||||
### Directory Structure
|
||||
|
||||
LuHost automatically detects your Luanti installation and creates the following structure:
|
||||
|
||||
```
|
||||
~/.minetest/ # Default Luanti directory
|
||||
├── minetest.conf # Server configuration
|
||||
├── worlds/ # World files
|
||||
├── mods/ # Installed mods
|
||||
└── games/ # Game definitions
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
LuHost provides a REST API for programmatic access:
|
||||
|
||||
### Server Management
|
||||
- `GET /api/server/status` - Get server status
|
||||
- `POST /api/server/start` - Start server
|
||||
- `POST /api/server/stop` - Stop server
|
||||
- `POST /api/server/restart` - Restart server
|
||||
- `POST /api/server/command` - Send server command
|
||||
|
||||
### World Management
|
||||
- `GET /api/worlds` - List all worlds
|
||||
- `POST /api/worlds/:name/backup` - Create world backup
|
||||
- `DELETE /api/worlds/:name` - Delete world
|
||||
|
||||
### Player Management
|
||||
- `POST /api/server/command` - Send server commands (including kick players)
|
||||
|
||||
### Configuration
|
||||
- `GET /api/config` - Get current configuration
|
||||
- `POST /api/config` - Update configuration
|
||||
- `POST /api/config/reset/:section` - Reset section to defaults
|
||||
|
||||
### ContentDB
|
||||
- `GET /api/contentdb/packages` - Browse ContentDB packages
|
||||
- `GET /api/contentdb/search` - Search packages
|
||||
- `POST /api/contentdb/install` - Install package
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Real-time updates are provided via WebSocket:
|
||||
|
||||
### Server Events
|
||||
- `server:status` - Server status changes with external server detection
|
||||
- `server:log` - New log entries in real-time
|
||||
- `server:players` - Player list updates with activity tracking
|
||||
- `server:stats` - Server performance statistics
|
||||
|
||||
### System Events
|
||||
- `configUpdate` - Configuration changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Server won't start**
|
||||
- Check that Luanti is properly installed (`luanti --version` or `minetest --version`)
|
||||
- Verify the Luanti executable is in your PATH
|
||||
- Check server logs for specific error messages
|
||||
- Ensure worlds exist before trying to start server
|
||||
|
||||
**External server not detected**
|
||||
- External servers are detected automatically if running
|
||||
- Only servers started independently (not through LuHost) are marked as external
|
||||
- External servers have limited control - monitoring only
|
||||
|
||||
**Permission errors**
|
||||
- Ensure LuHost has read/write access to Luanti directories
|
||||
- On Linux/Mac, you may need to adjust file permissions: `chmod -R 755 ~/.minetest`
|
||||
|
||||
**Port conflicts**
|
||||
- Default web port is 3000, game server port is 30000
|
||||
- Change ports if conflicts occur with other services
|
||||
- Use `PORT=3001 npm start` to run on different port
|
||||
|
||||
**Player kick not working**
|
||||
- Kick functionality only works on servers managed by LuHost
|
||||
- External servers show disabled kick buttons with explanatory tooltips
|
||||
- Ensure you have proper authentication when using server commands
|
||||
|
||||
**WebSocket connection issues**
|
||||
- Check firewall settings
|
||||
- Verify that WebSocket connections aren't blocked by proxy/firewall
|
||||
|
||||
### Log Files
|
||||
|
||||
LuHost logs important events to the console. For persistent logging:
|
||||
|
||||
```bash
|
||||
npm start > luhost.log 2>&1
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
For issues and questions:
|
||||
1. Check the troubleshooting section above
|
||||
2. Review server logs for error messages
|
||||
3. Verify your Luanti installation is working independently
|
||||
4. Check file permissions and directory access
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
├── app.js # Main application entry point
|
||||
├── package.json # Dependencies and scripts
|
||||
├── routes/ # Express route handlers
|
||||
│ ├── auth.js # Authentication routes
|
||||
│ ├── api.js # API endpoints
|
||||
│ ├── server.js # Server management
|
||||
│ ├── config.js # Configuration management
|
||||
│ ├── worlds.js # World management
|
||||
│ ├── users.js # User management
|
||||
│ ├── extensions.js # Extensions (mods/games) management
|
||||
│ └── contentdb.js # ContentDB integration
|
||||
├── views/ # EJS templates
|
||||
│ ├── layout.ejs # Base template
|
||||
│ ├── dashboard.ejs # Main dashboard
|
||||
│ ├── auth/ # Authentication views
|
||||
│ ├── server/ # Server management views
|
||||
│ ├── config/ # Configuration views
|
||||
│ ├── worlds/ # World management views
|
||||
│ ├── users/ # User management views
|
||||
│ ├── extensions/ # Extensions management views
|
||||
│ └── contentdb/ # ContentDB browser views
|
||||
├── utils/ # Utility modules
|
||||
│ ├── server-manager.js # Server process management
|
||||
│ ├── shared-server-manager.js # Shared server manager instance
|
||||
│ ├── config-manager.js # Configuration handling
|
||||
│ ├── config-parser.js # Configuration file parsing
|
||||
│ ├── contentdb.js # ContentDB API client
|
||||
│ ├── auth.js # Authentication utilities
|
||||
│ ├── paths.js # Path resolution
|
||||
│ └── security-logger.js # Security event logging
|
||||
├── middleware/ # Express middleware
|
||||
│ ├── auth.js # Authentication middleware
|
||||
│ └── security.js # Security middleware
|
||||
├── public/ # Static assets
|
||||
│ ├── css/ # Stylesheets
|
||||
│ ├── js/ # Client-side JavaScript
|
||||
│ │ ├── main.js # Global JavaScript
|
||||
│ │ ├── server.js # Server management page
|
||||
│ │ └── shared-status.js # Shared status updates
|
||||
│ └── images/ # Images and icons
|
||||
└── data/ # Application data
|
||||
├── sessions.db # User sessions
|
||||
├── users.db # User accounts
|
||||
└── packages.db # Package registry cache
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install development dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Run in development mode**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Development features**
|
||||
- Automatic server restart on file changes (via nodemon)
|
||||
- Detailed error logging
|
||||
- Development-friendly settings
|
||||
|
||||
### Key Implementation Notes
|
||||
|
||||
**External Server Detection**
|
||||
- The system automatically detects external Luanti servers via process scanning
|
||||
- External servers are monitored but have limited control capabilities
|
||||
- Player data is extracted from debug.txt parsing for external servers
|
||||
- UI clearly distinguishes between managed and external servers
|
||||
|
||||
**Real-time Features**
|
||||
- WebSocket integration provides live updates without page refreshes
|
||||
- Server status, player lists, and logs update automatically
|
||||
- Shared server manager instance ensures consistency across pages
|
||||
|
||||
**Security Architecture**
|
||||
- Multi-layered security with authentication, CSRF protection, and rate limiting
|
||||
- Input validation and XSS protection on all user inputs
|
||||
- Session-based authentication with secure cookie handling
|
||||
- Comprehensive security logging for audit purposes
|
||||
|
||||
**Player Management**
|
||||
- Intelligent player detection from server logs with activity classification
|
||||
- False positive filtering (excludes entities, explosions, etc.)
|
||||
- Real-time player activity tracking with kick functionality for managed servers
|
||||
- Player list automatically updates as players join/leave or become active/inactive
|
||||
|
||||
### Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly with both internal and external servers
|
||||
5. Ensure security features remain intact
|
||||
6. Submit a pull request
|
||||
|
||||
### AI-Enabled Development Notes
|
||||
|
||||
When working on this codebase with AI assistance:
|
||||
|
||||
1. **Server Manager** - The core logic is in `utils/server-manager.js` with external server detection
|
||||
2. **Authentication** - All routes require authentication; check `middleware/auth.js` for patterns
|
||||
3. **Real-time Updates** - WebSocket events are defined in `routes/api.js` and handled in client-side JS
|
||||
4. **Player Detection** - Complex logic in `getExternalServerPlayerData()` method with filtering rules
|
||||
5. **Security** - Multiple layers; always validate inputs and check existing patterns
|
||||
6. **Database** - SQLite databases for sessions, users, and package cache
|
||||
7. **File Structure** - Follow existing patterns in routes, views, and utilities
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Luanti Project** - For the amazing voxel game engine
|
||||
- **ContentDB** - For the mod and game distribution platform
|
290
app.js
Normal file
290
app.js
Normal file
@@ -0,0 +1,290 @@
|
||||
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 };
|
61
middleware/auth.js
Normal file
61
middleware/auth.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Authentication middleware
|
||||
const AuthManager = require('../utils/auth');
|
||||
const authManager = new AuthManager();
|
||||
|
||||
// Initialize auth manager
|
||||
authManager.initialize().catch(console.error);
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.user) {
|
||||
// User is authenticated
|
||||
return next();
|
||||
} else {
|
||||
// User is not authenticated - check if this is first user setup
|
||||
try {
|
||||
const isFirstUser = await authManager.isFirstUser();
|
||||
|
||||
if (isFirstUser) {
|
||||
// No users exist yet - redirect to registration
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(401).json({ error: 'No users configured. Please complete setup.' });
|
||||
} else {
|
||||
return res.redirect('/register');
|
||||
}
|
||||
} else {
|
||||
// Users exist but this person isn't authenticated
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
} else {
|
||||
return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking first user in auth middleware:', error);
|
||||
// Fallback to login on error
|
||||
return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redirectIfAuthenticated(req, res, next) {
|
||||
if (req.session && req.session.user) {
|
||||
// User is already authenticated, redirect to dashboard
|
||||
return res.redirect('/');
|
||||
} else {
|
||||
// User is not authenticated, continue to login/register
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
function attachUser(req, res, next) {
|
||||
// Make user available to templates
|
||||
res.locals.user = req.session ? req.session.user : null;
|
||||
res.locals.isAuthenticated = !!(req.session && req.session.user);
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireAuth,
|
||||
redirectIfAuthenticated,
|
||||
attachUser
|
||||
};
|
185
middleware/security.js
Normal file
185
middleware/security.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// Security middleware for input validation and CSRF protection
|
||||
|
||||
/**
|
||||
* Input validation middleware
|
||||
* Validates common input patterns and sanitizes data
|
||||
*/
|
||||
function validateInput(req, res, next) {
|
||||
// Sanitize query parameters
|
||||
for (const key in req.query) {
|
||||
if (typeof req.query[key] === 'string') {
|
||||
// Remove control characters
|
||||
req.query[key] = req.query[key].replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (req.query[key].length > 1000) {
|
||||
req.query[key] = req.query[key].substring(0, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize body data for non-JSON requests
|
||||
if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
|
||||
for (const key in req.body) {
|
||||
if (typeof req.body[key] === 'string') {
|
||||
// Remove control characters but preserve newlines for textareas
|
||||
if (key.includes('description') || key.includes('content') || key.includes('motd')) {
|
||||
req.body[key] = req.body[key].replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
} else {
|
||||
req.body[key] = req.body[key].replace(/[\x00-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
// Limit length based on field type
|
||||
const maxLength = getMaxLengthForField(key);
|
||||
if (req.body[key].length > maxLength) {
|
||||
req.body[key] = req.body[key].substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed length for different field types
|
||||
*/
|
||||
function getMaxLengthForField(fieldName) {
|
||||
const fieldLimits = {
|
||||
// User authentication fields
|
||||
'username': 50,
|
||||
'password': 200,
|
||||
'confirmPassword': 200,
|
||||
'currentPassword': 200,
|
||||
'newPassword': 200,
|
||||
|
||||
// Server/world names
|
||||
'name': 100,
|
||||
'worldName': 100,
|
||||
'serverName': 200,
|
||||
|
||||
// Text content
|
||||
'description': 2000,
|
||||
'motd': 500,
|
||||
'content': 5000,
|
||||
|
||||
// Commands and paths
|
||||
'command': 500,
|
||||
'path': 500,
|
||||
|
||||
// Network settings
|
||||
'bind': 100,
|
||||
'serverlist_url': 500,
|
||||
|
||||
// Default
|
||||
'default': 200
|
||||
};
|
||||
|
||||
return fieldLimits[fieldName] || fieldLimits['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS protection middleware
|
||||
* Escapes HTML in user input for specific fields
|
||||
*/
|
||||
function xssProtection(req, res, next) {
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
// Fields that should be HTML escaped
|
||||
const fieldsToEscape = ['username', 'name', 'worldName', 'serverName', 'motd'];
|
||||
|
||||
for (const field of fieldsToEscape) {
|
||||
if (req.body[field] && typeof req.body[field] === 'string') {
|
||||
req.body[field] = escapeHtml(req.body[field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic HTML escape function
|
||||
*/
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Security headers middleware (additional to helmet)
|
||||
*/
|
||||
function additionalSecurityHeaders(req, res, next) {
|
||||
// Prevent MIME type sniffing
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Prevent clickjacking
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
|
||||
// XSS protection
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Referrer policy
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request size validation
|
||||
*/
|
||||
function validateRequestSize(req, res, next) {
|
||||
// Check for unusually large requests that might indicate an attack
|
||||
const contentLength = req.headers['content-length'];
|
||||
if (contentLength && parseInt(contentLength) > 50 * 1024 * 1024) { // 50MB limit
|
||||
return res.status(413).json({ error: 'Request too large' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Path traversal protection
|
||||
*/
|
||||
function pathTraversalProtection(req, res, next) {
|
||||
// Check for path traversal attempts in various parameters
|
||||
const suspiciousPatterns = ['../', '..\\', '%2e%2e%2f', '%2e%2e%5c'];
|
||||
|
||||
function checkForTraversal(obj, path = '') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
const lowerValue = value.toLowerCase();
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (lowerValue.includes(pattern)) {
|
||||
console.warn(`Path traversal attempt detected: ${path}${key} = ${value}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
if (checkForTraversal(value, `${path}${key}.`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((req.query && checkForTraversal(req.query)) ||
|
||||
(req.body && checkForTraversal(req.body))) {
|
||||
return res.status(400).json({ error: 'Invalid request parameters' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateInput,
|
||||
xssProtection,
|
||||
additionalSecurityHeaders,
|
||||
validateRequestSize,
|
||||
pathTraversalProtection,
|
||||
escapeHtml
|
||||
};
|
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "luhost",
|
||||
"version": "1.0.0",
|
||||
"description": "LuHost - A modern web interface for Luanti (Minetest) server management with ContentDB integration",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "nodemon app.js"
|
||||
},
|
||||
"keywords": [
|
||||
"luhost",
|
||||
"luanti",
|
||||
"minetest",
|
||||
"server",
|
||||
"management",
|
||||
"web",
|
||||
"contentdb",
|
||||
"admin"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chokidar": "^3.5.3",
|
||||
"compression": "^1.7.4",
|
||||
"connect-sqlite3": "^0.9.13",
|
||||
"cors": "^2.8.5",
|
||||
"csurf": "^1.11.0",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-session": "^1.17.3",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"socket.io": "^4.7.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
847
public/css/style.css
Normal file
847
public/css/style.css
Normal file
@@ -0,0 +1,847 @@
|
||||
/* Luanti/Minecraft-Inspired Blocky Design */
|
||||
/* Using only local resources - no external fonts or CDNs */
|
||||
|
||||
/* Modern CSS Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* CSS Variables for blocky Luanti theme */
|
||||
:root {
|
||||
/* Minecraft/Luanti color palette */
|
||||
--stone-color: #7F7F7F;
|
||||
--cobblestone-color: #5A5A5A;
|
||||
--dirt-color: #8B5A2B;
|
||||
--grass-color: #79C05A;
|
||||
--grass-dark: #5A8F40;
|
||||
--wood-color: #C4965C;
|
||||
--wood-dark: #8B6A3C;
|
||||
--diamond-color: #64FFDA;
|
||||
--emerald-color: #00C851;
|
||||
--redstone-color: #FF0000;
|
||||
--lapis-color: #1976D2;
|
||||
--iron-color: #C0C0C0;
|
||||
--gold-color: #FFD700;
|
||||
--coal-color: #2C2C2C;
|
||||
--water-color: #4FC3F7;
|
||||
|
||||
/* Theme colors using Luanti palette */
|
||||
--primary-color: var(--lapis-color);
|
||||
--primary-hover: #1565C0;
|
||||
--success-color: var(--emerald-color);
|
||||
--success-hover: #00A142;
|
||||
--danger-color: var(--redstone-color);
|
||||
--danger-hover: #CC0000;
|
||||
--warning-color: var(--gold-color);
|
||||
--warning-hover: #E6C200;
|
||||
--secondary-color: var(--stone-color);
|
||||
--secondary-hover: var(--cobblestone-color);
|
||||
|
||||
/* Blocky backgrounds */
|
||||
--bg-primary: #F5F5F5;
|
||||
--bg-secondary: #E8E8E8;
|
||||
--bg-accent: #DCDCDC;
|
||||
--bg-dark: var(--coal-color);
|
||||
--text-primary: #2C2C2C;
|
||||
--text-secondary: #5A5A5A;
|
||||
--text-light: #F5F5F5;
|
||||
--border-color: #A0A0A0;
|
||||
--border-dark: #5A5A5A;
|
||||
|
||||
/* Blocky shadows - more pronounced */
|
||||
--shadow-block: 4px 4px 0px rgba(0, 0, 0, 0.3);
|
||||
--shadow-block-hover: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
--shadow-inset: inset 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Sharp, blocky borders */
|
||||
--border-width: 3px;
|
||||
--radius-none: 0;
|
||||
--radius-small: 2px;
|
||||
}
|
||||
|
||||
/* Base styles with blocky font stack */
|
||||
body {
|
||||
font-family: 'Courier New', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: bold;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
|
||||
/* Create a subtle texture pattern */
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.02) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.02) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
|
||||
}
|
||||
|
||||
/* Container and layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Blocky Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--grass-color) 0%, var(--grass-dark) 100%);
|
||||
border: var(--border-width) solid var(--border-dark);
|
||||
border-bottom: 6px solid var(--dirt-color);
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--grass-color) 0%,
|
||||
var(--grass-dark) 25%,
|
||||
var(--grass-color) 50%,
|
||||
var(--grass-dark) 75%,
|
||||
var(--grass-color) 100%);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-light);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Blocky Navigation */
|
||||
.nav {
|
||||
display: flex;
|
||||
background: var(--stone-color);
|
||||
border: var(--border-width) solid var(--border-dark);
|
||||
border-bottom: 6px solid var(--cobblestone-color);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 16px 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.1s ease;
|
||||
border-right: var(--border-width) solid var(--border-dark);
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(0, 0, 0, 0.1) 100%);
|
||||
}
|
||||
|
||||
.nav-link:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--cobblestone-color);
|
||||
transform: translateY(2px);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-light);
|
||||
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
/* Blocky Cards */
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.3) 100%);
|
||||
}
|
||||
|
||||
.card h2, .card h3, .card h4 {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Blocky Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border: var(--border-width) solid;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-family: inherit;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(2px);
|
||||
box-shadow: var(--shadow-block-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
border-color: var(--secondary-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.8rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 16px 24px;
|
||||
font-size: 1.1rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Blocky Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 4px solid var(--border-dark);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.1s ease;
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-hover);
|
||||
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: var(--bg-accent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Blocky Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--bg-primary);
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
border-right: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table th:last-child,
|
||||
.table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--stone-color);
|
||||
color: var(--text-light);
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--bg-accent);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Blocky Status Badges */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: 2px solid;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-family: inherit;
|
||||
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.status-starting {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Blocky Logs - Terminal style */
|
||||
.logs, .log-container {
|
||||
background: var(--bg-dark);
|
||||
color: var(--emerald-color);
|
||||
padding: 16px;
|
||||
border: var(--border-width) solid var(--cobblestone-color);
|
||||
border-bottom: 6px solid var(--coal-color);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.6;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.log-line.text-muted {
|
||||
color: var(--stone-color) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for logs */
|
||||
.logs::-webkit-scrollbar,
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-track,
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: var(--cobblestone-color);
|
||||
border: 2px solid var(--coal-color);
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-thumb,
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: var(--stone-color);
|
||||
border: 2px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-thumb:hover,
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--iron-color);
|
||||
}
|
||||
|
||||
/* Stats Cards - Blocky style */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--wood-color);
|
||||
border: var(--border-width) solid var(--wood-dark);
|
||||
border-bottom: 6px solid var(--dirt-color);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.3) 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Blocky Alerts */
|
||||
.alert {
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: var(--border-width) solid;
|
||||
border-bottom-width: 6px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--water-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Grid layouts */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.col, .col-md-4, .col-md-6, .col-md-8 {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.nav-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border-right: none;
|
||||
border-bottom: 2px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.nav-link:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.col, .col-md-4, .col-md-6, .col-md-8 {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.btn:focus,
|
||||
.form-control:focus,
|
||||
.nav-link:focus {
|
||||
outline: 4px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.nav,
|
||||
.btn,
|
||||
.modal {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
break-inside: avoid;
|
||||
box-shadow: none;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional blocky elements */
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-wrapper input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Make everything more blocky and pronounced */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::before,
|
||||
input[type="radio"]:checked::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 2px;
|
||||
color: var(--success-color);
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Navbar pills for contentdb */
|
||||
.nav-pills .nav-link {
|
||||
background: var(--secondary-color);
|
||||
border: 3px solid var(--secondary-hover);
|
||||
border-radius: 0;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-primary);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
496
public/js/main.js
Normal file
496
public/js/main.js
Normal file
@@ -0,0 +1,496 @@
|
||||
// Main JavaScript for Luanti Web Server
|
||||
class LuantiWebServer {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.serverStatus = 'stopped';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize Socket.IO
|
||||
this.initSocket();
|
||||
|
||||
// Initialize UI components
|
||||
this.initUI();
|
||||
|
||||
// Initialize forms
|
||||
this.initForms();
|
||||
|
||||
// Initialize real-time updates
|
||||
this.initRealTime();
|
||||
}
|
||||
|
||||
initSocket() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.updateConnectionStatus(true);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
this.socket.on('serverStatus', (status) => {
|
||||
this.updateServerStatus(status);
|
||||
});
|
||||
|
||||
this.socket.on('serverLog', (logEntry) => {
|
||||
this.appendLogEntry(logEntry);
|
||||
});
|
||||
}
|
||||
|
||||
initUI() {
|
||||
// Modal functionality
|
||||
this.initModals();
|
||||
|
||||
// Tooltips
|
||||
this.initTooltips();
|
||||
|
||||
// Auto-refresh toggles
|
||||
this.initAutoRefresh();
|
||||
}
|
||||
|
||||
initModals() {
|
||||
// Generic modal handling
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-modal-open]')) {
|
||||
const modalId = e.target.getAttribute('data-modal-open');
|
||||
this.openModal(modalId);
|
||||
}
|
||||
|
||||
if (e.target.matches('[data-modal-close]') || e.target.closest('[data-modal-close]')) {
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
if (e.target.matches('.modal')) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first input in modal
|
||||
const firstInput = modal.querySelector('input, textarea, select');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
initTooltips() {
|
||||
// Simple tooltip implementation
|
||||
document.querySelectorAll('[data-tooltip]').forEach(element => {
|
||||
element.addEventListener('mouseenter', (e) => {
|
||||
this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showTooltip(element, text) {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
`;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.top = rect.top - tooltip.offsetHeight - 8 + 'px';
|
||||
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px';
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
const tooltip = document.querySelector('.tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}
|
||||
|
||||
initAutoRefresh() {
|
||||
const autoRefreshElements = document.querySelectorAll('[data-auto-refresh]');
|
||||
autoRefreshElements.forEach(element => {
|
||||
const interval = parseInt(element.getAttribute('data-auto-refresh')) || 5000;
|
||||
const url = element.getAttribute('data-refresh-url') || window.location.href;
|
||||
|
||||
setInterval(async () => {
|
||||
if (element.checked || element.getAttribute('data-auto-refresh-active') === 'true') {
|
||||
await this.refreshElement(element, url);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshElement(element, url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
// This would need specific implementation per element type
|
||||
console.log('Auto-refresh triggered for', element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initForms() {
|
||||
// AJAX form submission
|
||||
document.addEventListener('submit', async (e) => {
|
||||
if (e.target.matches('[data-ajax-form]')) {
|
||||
e.preventDefault();
|
||||
await this.submitAjaxForm(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time form validation
|
||||
this.initFormValidation();
|
||||
}
|
||||
|
||||
async submitAjaxForm(form) {
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
try {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Processing...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method || 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const result = await response.json();
|
||||
this.showAlert(result.message || 'Success', 'success');
|
||||
} else {
|
||||
// Handle redirect or page reload
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
const error = await response.text();
|
||||
this.showAlert(error || 'An error occurred', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Network error: ' + error.message, 'danger');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
initFormValidation() {
|
||||
// Real-time validation for world/mod names
|
||||
document.addEventListener('input', (e) => {
|
||||
if (e.target.matches('[data-validate-name]')) {
|
||||
this.validateName(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateName(input) {
|
||||
const value = input.value;
|
||||
const isValid = /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 50;
|
||||
|
||||
input.setCustomValidity(isValid ? '' : 'Only letters, numbers, underscore and hyphen allowed (max 50 chars)');
|
||||
|
||||
// Visual feedback
|
||||
input.classList.toggle('is-invalid', !isValid && value.length > 0);
|
||||
input.classList.toggle('is-valid', isValid && value.length > 0);
|
||||
}
|
||||
|
||||
initRealTime() {
|
||||
// Auto-scroll logs
|
||||
this.initLogAutoScroll();
|
||||
}
|
||||
|
||||
initLogAutoScroll() {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (logsContainer) {
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
const observer = new MutationObserver(() => {
|
||||
if (logsContainer.scrollTop + logsContainer.clientHeight >= logsContainer.scrollHeight - 100) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(logsContainer, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = connected ? 'status status-running' : 'status status-error';
|
||||
statusElement.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
updateServerStatus(status) {
|
||||
this.serverStatus = status.status;
|
||||
|
||||
// Update status badge
|
||||
const statusElement = document.getElementById('server-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = `status status-${status.status}`;
|
||||
statusElement.textContent = status.status.charAt(0).toUpperCase() + status.status.slice(1);
|
||||
}
|
||||
|
||||
// Update PID
|
||||
const pidElement = document.getElementById('server-pid');
|
||||
if (pidElement) {
|
||||
pidElement.textContent = status.pid || 'N/A';
|
||||
}
|
||||
|
||||
// Update uptime
|
||||
const uptimeElement = document.getElementById('server-uptime');
|
||||
if (uptimeElement) {
|
||||
uptimeElement.textContent = this.formatUptime(status.uptime);
|
||||
}
|
||||
|
||||
// Update control buttons
|
||||
this.updateServerControls(status.status);
|
||||
}
|
||||
|
||||
updateServerControls(status) {
|
||||
const startBtn = document.getElementById('server-start');
|
||||
const stopBtn = document.getElementById('server-stop');
|
||||
const restartBtn = document.getElementById('server-restart');
|
||||
|
||||
if (startBtn) startBtn.disabled = status === 'running';
|
||||
if (stopBtn) stopBtn.disabled = status === 'stopped';
|
||||
if (restartBtn) restartBtn.disabled = status === 'stopped';
|
||||
}
|
||||
|
||||
appendLogEntry(logEntry) {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (!logsContainer) return;
|
||||
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = 'log-entry';
|
||||
|
||||
if (typeof logEntry === 'string') {
|
||||
logElement.textContent = logEntry;
|
||||
} else {
|
||||
logElement.innerHTML = `
|
||||
<span class="log-timestamp">[${new Date(logEntry.timestamp).toLocaleTimeString()}]</span>
|
||||
<span class="log-level-${logEntry.level}">${logEntry.message}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
logsContainer.appendChild(logElement);
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const logEntries = logsContainer.children;
|
||||
if (logEntries.length > 1000) {
|
||||
logEntries[0].remove();
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
showAlert(message, type = 'info', duration = 5000) {
|
||||
const alertsContainer = document.getElementById('alerts') || this.createAlertsContainer();
|
||||
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button type="button" class="modal-close" style="margin-left: auto;">×</button>
|
||||
`;
|
||||
|
||||
alertsContainer.appendChild(alert);
|
||||
|
||||
// Auto-remove alert
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.remove();
|
||||
}
|
||||
}, duration);
|
||||
|
||||
// Manual close
|
||||
alert.querySelector('.modal-close').addEventListener('click', () => {
|
||||
alert.remove();
|
||||
});
|
||||
}
|
||||
|
||||
createAlertsContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'alerts';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async api(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.luantiWebServer = new LuantiWebServer();
|
||||
});
|
||||
|
||||
// Global utility functions
|
||||
window.confirmDelete = function(itemType, itemName) {
|
||||
console.log(`confirmDelete called with: itemType="${itemType}", itemName="${itemName}"`);
|
||||
|
||||
if (itemType === 'world') {
|
||||
// Extra confirmation for world deletion - require typing the world name
|
||||
const message = `WARNING: You are about to permanently delete the world "${itemName}".\n\n` +
|
||||
`This will remove ALL world data including:\n` +
|
||||
`• All builds and constructions\n` +
|
||||
`• Player inventories and progress\n` +
|
||||
`• World settings and configuration\n` +
|
||||
`• All world-specific mods and data\n\n` +
|
||||
`This action cannot be undone!\n\n` +
|
||||
`Type the world name exactly to confirm:`;
|
||||
|
||||
const confirmation = prompt(message);
|
||||
console.log(`User entered: "${confirmation}", expected: "${itemName}"`);
|
||||
|
||||
if (confirmation === null) {
|
||||
console.log('User cancelled the dialog');
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = confirmation === itemName;
|
||||
console.log(`Confirmation ${matches ? 'matches' : 'does not match'}`);
|
||||
|
||||
if (!matches && confirmation !== null) {
|
||||
alert('Deletion cancelled - world name did not match exactly.');
|
||||
}
|
||||
|
||||
return matches;
|
||||
} else {
|
||||
// Standard confirmation for other items
|
||||
return confirm(`Are you sure you want to delete the ${itemType} "${itemName}"? This action cannot be undone.`);
|
||||
}
|
||||
};
|
||||
|
||||
window.showLoading = function(element, text = 'Loading...') {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
window.hideLoading = function(element) {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
};
|
629
public/js/server.js
Normal file
629
public/js/server.js
Normal file
@@ -0,0 +1,629 @@
|
||||
let socket;
|
||||
let autoScroll = true;
|
||||
let serverRunning = false;
|
||||
let isExternalServer = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize WebSocket connection for real-time updates
|
||||
initializeWebSocket();
|
||||
|
||||
// Load initial data
|
||||
loadWorlds();
|
||||
updateServerStatus();
|
||||
|
||||
// Set up periodic status updates (every 3 seconds for better responsiveness)
|
||||
setInterval(updateServerStatus, 3000);
|
||||
|
||||
// Add event listeners for buttons
|
||||
document.getElementById('startBtn').addEventListener('click', startServer);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopServer);
|
||||
document.getElementById('restartBtn').addEventListener('click', restartServer);
|
||||
document.getElementById('downloadBtn').addEventListener('click', downloadLogs);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearLogs);
|
||||
document.getElementById('autoScrollBtn').addEventListener('click', toggleAutoScroll);
|
||||
document.getElementById('sendBtn').addEventListener('click', sendCommand);
|
||||
|
||||
// Add enter key handler for console input
|
||||
document.getElementById('consoleInput').addEventListener('keypress', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendCommand();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function initializeWebSocket() {
|
||||
socket = io();
|
||||
|
||||
socket.on('server:log', function(logEntry) {
|
||||
addLogEntry(logEntry.type, logEntry.content, logEntry.timestamp);
|
||||
});
|
||||
|
||||
socket.on('server:status', function(status) {
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
});
|
||||
|
||||
socket.on('server:players', function(players) {
|
||||
updatePlayersList(players, isExternalServer);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to check server status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusDisplay(status) {
|
||||
const statusLight = document.getElementById('statusLight');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const consoleInputGroup = document.getElementById('consoleInputGroup');
|
||||
|
||||
const wasRunning = serverRunning;
|
||||
serverRunning = status.isRunning;
|
||||
|
||||
if (status.isRunning) {
|
||||
if (status.isReady) {
|
||||
// Server is running and ready to accept connections
|
||||
statusLight.className = 'status-light online';
|
||||
statusText.textContent = status.isExternal ? 'Running (External - Monitor Only)' : 'Running';
|
||||
} else {
|
||||
// Server process is running but not ready yet
|
||||
statusLight.className = 'status-light starting';
|
||||
statusText.textContent = 'Starting...';
|
||||
}
|
||||
|
||||
// For external servers, disable control buttons
|
||||
if (status.isExternal) {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
} else {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
restartBtn.disabled = false;
|
||||
consoleInputGroup.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
statusLight.className = 'status-light offline';
|
||||
statusText.textContent = 'Offline';
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
|
||||
|
||||
// Reset button states if server stopped unexpectedly
|
||||
if (startBtn.textContent === '⏳ Starting...') {
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
if (restartBtn.textContent === '⏳ Restarting...') {
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
|
||||
// Log if server stopped unexpectedly
|
||||
if (wasRunning && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server has stopped. Check logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
document.getElementById('uptime').textContent = formatUptime(status.uptime);
|
||||
document.getElementById('playerCount').textContent = status.players || 0;
|
||||
document.getElementById('memoryUsage').textContent = status.memoryUsage ?
|
||||
Math.round(status.memoryUsage) + ' MB' : '--';
|
||||
|
||||
// Debug: Log the status to see what we're getting
|
||||
console.log('Server status update:', {
|
||||
isRunning: status.isRunning,
|
||||
players: status.players,
|
||||
uptime: status.uptime
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(milliseconds) {
|
||||
if (!milliseconds) return '--';
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
|
||||
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
|
||||
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
|
||||
return seconds + 's';
|
||||
}
|
||||
|
||||
async function loadWorlds() {
|
||||
console.log('loadWorlds() called');
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
console.log('Response status:', response.status, 'Content-Type:', contentType);
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for loading worlds');
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Please log in to load worlds</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>🔒 Authentication required</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const worlds = await response.json();
|
||||
console.log('Worlds received:', worlds);
|
||||
const worldSelect = document.getElementById('worldSelect');
|
||||
|
||||
if (worlds.length === 0) {
|
||||
worldSelect.innerHTML =
|
||||
'<option value="">No worlds found - server will create default world</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
|
||||
} else {
|
||||
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
||||
worlds.forEach(world => {
|
||||
const option = document.createElement('option');
|
||||
option.value = world.name;
|
||||
option.textContent = '🌍 ' + (world.displayName || world.name);
|
||||
worldSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Error loading worlds - will use defaults</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
console.log('Starting server with world:', worldName);
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before starting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = '⏳ Starting...';
|
||||
|
||||
const response = await fetch('/api/server/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName })
|
||||
});
|
||||
|
||||
// Check if response is HTML (redirect to login) instead of JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
// Optionally redirect to login
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server started successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly. Check logs for errors.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to start server: ' + (result.error || 'Unknown error'));
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server start error:', error);
|
||||
addLogEntry('error', 'Failed to start server: ' + error.message);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer() {
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
|
||||
try {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = '⏳ Stopping...';
|
||||
|
||||
const response = await fetch('/api/server/stop', { method: 'POST' });
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server stopped successfully');
|
||||
await updateServerStatus();
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to stop server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server stop error:', error);
|
||||
addLogEntry('error', 'Failed to stop server: ' + error.message);
|
||||
} finally {
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before restarting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
restartBtn.disabled = true;
|
||||
restartBtn.textContent = '⏳ Restarting...';
|
||||
|
||||
const response = await fetch('/api/server/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName || null })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server restarted successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly after restart. Check logs for errors.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to restart server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server restart error:', error);
|
||||
addLogEntry('error', 'Failed to restart server: ' + error.message);
|
||||
} finally {
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}
|
||||
|
||||
function addLogEntry(type, message, timestamp) {
|
||||
const consoleContent = document.getElementById('consoleContent');
|
||||
const logEntry = document.createElement('div');
|
||||
|
||||
timestamp = timestamp || new Date().toLocaleTimeString();
|
||||
|
||||
logEntry.className = 'log-entry ' + type;
|
||||
logEntry.innerHTML = '<span class="timestamp">' + timestamp + '</span>' +
|
||||
'<span class="message">' + escapeHtml(message) + '</span>';
|
||||
|
||||
consoleContent.appendChild(logEntry);
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if (autoScroll) {
|
||||
consoleContent.scrollTop = consoleContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const maxEntries = 1000;
|
||||
while (consoleContent.children.length > maxEntries) {
|
||||
consoleContent.removeChild(consoleContent.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('consoleContent').innerHTML = '';
|
||||
addLogEntry('info', 'Console cleared');
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
document.getElementById('autoScrollText').textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
||||
}
|
||||
|
||||
async function sendCommand() {
|
||||
const input = document.getElementById('consoleInput');
|
||||
const command = input.value.trim();
|
||||
|
||||
if (!command) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', 'Command sent: ' + command);
|
||||
input.value = '';
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to send command: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send command error:', error);
|
||||
addLogEntry('error', 'Failed to send command: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayersList(players, isExternal) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
|
||||
if (!players || players.length === 0) {
|
||||
playersList.innerHTML = '<p class="text-muted">No players online</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a table for better formatting with kick functionality
|
||||
const playersHtml = '<table class="table table-sm">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>Player</th>' +
|
||||
'<th>Last Activity</th>' +
|
||||
'<th>Actions</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody>' +
|
||||
players.map((player, index) => {
|
||||
// Format the last seen time
|
||||
let lastActivity = '--';
|
||||
if (player.lastSeen) {
|
||||
const now = new Date();
|
||||
const lastSeenTime = new Date(player.lastSeen);
|
||||
const diffMinutes = Math.floor((now - lastSeenTime) / (1000 * 60));
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
lastActivity = 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
lastActivity = diffMinutes + 'm ago';
|
||||
} else {
|
||||
lastActivity = Math.floor(diffMinutes / 60) + 'h ago';
|
||||
}
|
||||
}
|
||||
|
||||
return '<tr>' +
|
||||
'<td><strong>' + escapeHtml(player.name) + '</strong></td>' +
|
||||
'<td>' +
|
||||
'<small class="text-muted">' + lastActivity + '</small><br>' +
|
||||
'<span class="badge badge-secondary">' + (player.lastAction || 'Active') + '</span>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn-sm btn-outline-danger kick-player-btn" data-player-name="' + escapeHtml(player.name) + '"' +
|
||||
(isExternal ? ' disabled title="Cannot kick players on external servers"' : '') + '>' +
|
||||
'<i class="fas fa-user-slash"></i> Kick' +
|
||||
'</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>';
|
||||
|
||||
playersList.innerHTML = playersHtml;
|
||||
|
||||
// Add event listeners for kick buttons
|
||||
const kickButtons = playersList.querySelectorAll('.kick-player-btn');
|
||||
kickButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const playerName = this.getAttribute('data-player-name');
|
||||
kickPlayer(playerName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function kickPlayer(playerName) {
|
||||
console.log('kickPlayer() called for player:', playerName);
|
||||
addLogEntry('info', 'Attempting to kick player: ' + playerName);
|
||||
|
||||
if (!confirm('Are you sure you want to kick ' + playerName + '?')) {
|
||||
console.log('Kick cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending kick request...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
command: '/kick ' + playerName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
addLogEntry('error', 'Authentication required to kick players. Please refresh the page.');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addLogEntry('success', 'Kicked player: ' + playerName);
|
||||
// Refresh player list after a short delay
|
||||
setTimeout(updateServerStatus, 1000);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to kick player: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error kicking player:', error);
|
||||
addLogEntry('error', 'Error kicking player: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/server/logs');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required to download logs');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'server-logs-' + new Date().toISOString().split('T')[0] + '.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to download logs: HTTP ' + response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download logs error:', error);
|
||||
addLogEntry('error', 'Failed to download logs: ' + error.message);
|
||||
}
|
||||
}
|
39
public/js/shared-status.js
Normal file
39
public/js/shared-status.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Shared server status functionality for all pages
|
||||
|
||||
async function updateServerStatus(statusElementId) {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updateStatusElement(statusElementId, status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
// Show error state
|
||||
const statusElement = document.getElementById(statusElementId);
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Error';
|
||||
statusElement.className = 'status status-stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusElement(elementId, status) {
|
||||
const statusElement = document.getElementById(elementId);
|
||||
if (statusElement) {
|
||||
const statusText = status.statusText || (status.isRunning ? 'running' : 'stopped');
|
||||
statusElement.textContent = statusText.charAt(0).toUpperCase() + statusText.slice(1);
|
||||
statusElement.className = `status status-${statusText}`;
|
||||
}
|
||||
}
|
534
routes/api.js
Normal file
534
routes/api.js
Normal file
@@ -0,0 +1,534 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const serverManager = require('../utils/shared-server-manager');
|
||||
const ConfigManager = require('../utils/config-manager');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
const appConfig = require('../utils/app-config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create global config manager instance
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
// Initialize server manager with socket.io when available
|
||||
let io = null;
|
||||
|
||||
function setSocketIO(socketInstance) {
|
||||
io = socketInstance;
|
||||
|
||||
// Attach server manager events to socket.io
|
||||
serverManager.on('log', (logEntry) => {
|
||||
if (io) {
|
||||
io.emit('server:log', logEntry);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('stats', (stats) => {
|
||||
if (io) {
|
||||
io.emit('server:stats', stats);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('status', (status) => {
|
||||
if (io) {
|
||||
io.emit('server:status', status);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('exit', (exitInfo) => {
|
||||
if (io) {
|
||||
// Broadcast status immediately when server exits
|
||||
serverManager.getServerStatus().then(status => {
|
||||
io.emit('server:status', status);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Server status endpoint
|
||||
router.get('/server/status', async (req, res) => {
|
||||
try {
|
||||
const status = await serverManager.getServerStatus();
|
||||
|
||||
// For all running servers, get player list from debug.txt
|
||||
let playerList = [];
|
||||
if (status.isRunning) {
|
||||
const playerData = await serverManager.getExternalServerPlayerData();
|
||||
playerList = playerData.players;
|
||||
|
||||
// Also update the server stats with current player count
|
||||
serverManager.serverStats.players = playerData.count;
|
||||
|
||||
// Emit player list via WebSocket if available
|
||||
if (io) {
|
||||
io.emit('server:players', playerList);
|
||||
}
|
||||
}
|
||||
|
||||
const isExternal = serverManager.serverProcess?.external || false;
|
||||
console.log('API: serverManager.serverProcess =', serverManager.serverProcess);
|
||||
console.log('API: isExternal =', isExternal);
|
||||
|
||||
console.log('API endpoint returning status:', {
|
||||
isRunning: status.isRunning,
|
||||
players: playerList.length, // Use the actual detected player count
|
||||
playerNames: playerList.map(p => p.name),
|
||||
statusText: status.isRunning ? 'running' : 'stopped',
|
||||
isExternal: isExternal
|
||||
});
|
||||
|
||||
res.json({
|
||||
...status,
|
||||
players: playerList.length, // Override with actual player count
|
||||
playerList: playerList,
|
||||
// Add simple string status for UI
|
||||
statusText: status.isRunning ? 'running' : 'stopped',
|
||||
// Include external server information
|
||||
isExternal: isExternal
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API: Server status error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
statusText: 'stopped',
|
||||
isRunning: false,
|
||||
isReady: false,
|
||||
playerList: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
router.post('/server/start', async (req, res) => {
|
||||
try {
|
||||
const { worldName } = req.body;
|
||||
console.log('Server start requested with world:', worldName);
|
||||
const result = await serverManager.startServer(worldName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Server start error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Stop server
|
||||
router.post('/server/stop', async (req, res) => {
|
||||
try {
|
||||
const { force = false } = req.body;
|
||||
const result = await serverManager.stopServer(force);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Restart server
|
||||
router.post('/server/restart', async (req, res) => {
|
||||
try {
|
||||
const { worldName } = req.body;
|
||||
const result = await serverManager.restartServer(worldName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Send command to server
|
||||
router.post('/server/command', async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
if (!command || typeof command !== 'string') {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
const result = await serverManager.sendCommand(command.trim());
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get server logs
|
||||
router.get('/server/logs', async (req, res) => {
|
||||
try {
|
||||
const { lines = 500, format = 'text' } = req.query;
|
||||
const logs = serverManager.getLogs(parseInt(lines));
|
||||
|
||||
if (format === 'json') {
|
||||
res.json(logs);
|
||||
} else {
|
||||
// Return as downloadable text file
|
||||
const logText = logs.map(log =>
|
||||
`[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}`
|
||||
).join('\n');
|
||||
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt');
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(logText);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get server info
|
||||
router.get('/server/info', async (req, res) => {
|
||||
try {
|
||||
const info = await serverManager.getServerInfo();
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Configuration endpoints
|
||||
|
||||
// Get all configuration sections
|
||||
router.get('/config/sections', async (req, res) => {
|
||||
try {
|
||||
const sections = configManager.getAllSettings();
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current configuration
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
// Use the new configuration schema approach instead of ConfigManager
|
||||
const configSchema = {
|
||||
System: {
|
||||
data_directory: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Luanti data directory path (leave empty for auto-detection)'
|
||||
}
|
||||
},
|
||||
Server: {
|
||||
port: {
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
description: 'Port for server to listen on'
|
||||
},
|
||||
server_name: {
|
||||
type: 'string',
|
||||
default: 'Luanti Server',
|
||||
description: 'Name of the server'
|
||||
},
|
||||
server_description: {
|
||||
type: 'string',
|
||||
default: 'A Luanti server',
|
||||
description: 'Server description'
|
||||
},
|
||||
server_announce: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Announce server to server list'
|
||||
},
|
||||
max_users: {
|
||||
type: 'number',
|
||||
default: 20,
|
||||
description: 'Maximum number of users'
|
||||
}
|
||||
},
|
||||
World: {
|
||||
creative_mode: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable creative mode by default'
|
||||
},
|
||||
enable_damage: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player damage by default'
|
||||
},
|
||||
enable_pvp: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player vs player combat by default'
|
||||
},
|
||||
default_game: {
|
||||
type: 'string',
|
||||
default: 'minetest_game',
|
||||
description: 'Default game to use for new worlds'
|
||||
},
|
||||
time_speed: {
|
||||
type: 'number',
|
||||
default: 72,
|
||||
description: 'Time speed (72 = normal, higher = faster)'
|
||||
}
|
||||
},
|
||||
Security: {
|
||||
disallow_empty_password: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Disallow empty passwords'
|
||||
},
|
||||
strict_protocol_version_checking: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Strict protocol version checking'
|
||||
}
|
||||
},
|
||||
Performance: {
|
||||
dedicated_server_step: {
|
||||
type: 'number',
|
||||
default: 0.1,
|
||||
description: 'Server step time in seconds'
|
||||
},
|
||||
num_emerge_threads: {
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description: 'Number of emerge threads'
|
||||
},
|
||||
server_map_save_interval: {
|
||||
type: 'number',
|
||||
default: 15.3,
|
||||
description: 'Map save interval in seconds'
|
||||
},
|
||||
max_block_send_distance: {
|
||||
type: 'number',
|
||||
default: 12,
|
||||
description: 'Maximum block send distance'
|
||||
}
|
||||
},
|
||||
Network: {
|
||||
server_address: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'IP address to bind to (empty for all interfaces)'
|
||||
},
|
||||
server_dedicated: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run as dedicated server'
|
||||
}
|
||||
},
|
||||
Advanced: {
|
||||
max_simultaneous_block_sends_per_client: {
|
||||
type: 'number',
|
||||
default: 40,
|
||||
description: 'Maximum simultaneous block sends per client'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load both Luanti config and app config
|
||||
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
|
||||
await appConfig.load();
|
||||
|
||||
// Combine configs for display
|
||||
const combinedConfig = {
|
||||
...luantiConfig,
|
||||
data_directory: appConfig.getDataDirectory()
|
||||
};
|
||||
|
||||
// Organize schema into sections with proper structure for frontend
|
||||
const sections = {};
|
||||
for (const [sectionName, sectionFields] of Object.entries(configSchema)) {
|
||||
sections[sectionName] = {
|
||||
description: sectionName + ' configuration settings',
|
||||
settings: sectionFields
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
current: combinedConfig,
|
||||
sections: sections,
|
||||
schema: configSchema
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting config via API:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update configuration
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).json({ error: 'Settings object is required' });
|
||||
}
|
||||
|
||||
// Validate all settings
|
||||
const validationErrors = [];
|
||||
const validatedSettings = {};
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const validation = configManager.validateSetting(key, value);
|
||||
if (validation.valid) {
|
||||
validatedSettings[key] = validation.value;
|
||||
} else {
|
||||
validationErrors.push({ key, error: validation.error });
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: validationErrors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await configManager.updateSettings(validatedSettings);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update single configuration setting
|
||||
router.put('/config/:key', async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
const validation = configManager.validateSetting(key, value);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const result = await configManager.updateSetting(key, validation.value);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset configuration section to defaults
|
||||
router.post('/config/reset/:section?', async (req, res) => {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const result = await configManager.resetToDefaults(section);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get setting information
|
||||
router.get('/config/setting/:key', async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const info = configManager.getSettingInfo(key);
|
||||
|
||||
if (!info) {
|
||||
return res.status(404).json({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Worlds endpoints (basic)
|
||||
router.get('/worlds', async (req, res) => {
|
||||
try {
|
||||
await fs.mkdir(paths.worldsDir, { recursive: true });
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
const worlds = [];
|
||||
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldPath = paths.getWorldPath(worldDir);
|
||||
const stats = await fs.stat(worldPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Try to read world.mt for display name
|
||||
let displayName = worldDir;
|
||||
try {
|
||||
const worldConfig = await fs.readFile(
|
||||
path.join(worldPath, 'world.mt'),
|
||||
'utf8'
|
||||
);
|
||||
const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/);
|
||||
if (nameMatch) {
|
||||
displayName = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: displayName,
|
||||
path: worldPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip invalid world directories
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last modified
|
||||
worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
|
||||
res.json(worlds);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ContentDB package info endpoint for validation
|
||||
router.post('/contentdb/package-info', async (req, res) => {
|
||||
try {
|
||||
const { author, name } = req.body;
|
||||
|
||||
if (!author || !name) {
|
||||
return res.status(400).json({ error: 'Author and name are required' });
|
||||
}
|
||||
|
||||
const ContentDBClient = require('../utils/contentdb');
|
||||
const contentdb = new ContentDBClient();
|
||||
|
||||
// Get package info from ContentDB
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
|
||||
res.json({
|
||||
type: packageInfo.type || 'mod',
|
||||
title: packageInfo.title || name,
|
||||
author: packageInfo.author || author,
|
||||
name: packageInfo.name || name,
|
||||
short_description: packageInfo.short_description || ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting package info:', error);
|
||||
|
||||
// If it's a 404 error, return that specifically
|
||||
if (error.message === 'Package not found') {
|
||||
return res.status(404).json({ error: 'Package not found on ContentDB' });
|
||||
}
|
||||
|
||||
// For other errors, return a generic error but don't fail completely
|
||||
res.status(200).json({
|
||||
error: 'Could not verify package information',
|
||||
type: 'mod', // Default to mod type
|
||||
fallback: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
router,
|
||||
setSocketIO,
|
||||
serverManager,
|
||||
configManager
|
||||
};
|
262
routes/auth.js
Normal file
262
routes/auth.js
Normal file
@@ -0,0 +1,262 @@
|
||||
const express = require('express');
|
||||
const AuthManager = require('../utils/auth');
|
||||
const { redirectIfAuthenticated } = require('../middleware/auth');
|
||||
const securityLogger = require('../utils/security-logger');
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = new AuthManager();
|
||||
|
||||
// Initialize auth manager
|
||||
authManager.initialize().catch(console.error);
|
||||
|
||||
// Login page
|
||||
router.get('/login', redirectIfAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const isFirstUser = await authManager.isFirstUser();
|
||||
|
||||
if (isFirstUser) {
|
||||
// No users exist yet - redirect to registration
|
||||
return res.redirect('/register');
|
||||
}
|
||||
|
||||
const redirectUrl = req.query.redirect || '/';
|
||||
|
||||
res.render('auth/login', {
|
||||
title: 'Login',
|
||||
redirectUrl: redirectUrl,
|
||||
currentPage: 'login'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking first user on login:', error);
|
||||
const redirectUrl = req.query.redirect || '/';
|
||||
|
||||
res.render('auth/login', {
|
||||
title: 'Login',
|
||||
redirectUrl: redirectUrl,
|
||||
currentPage: 'login'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Register page (only for first user)
|
||||
router.get('/register', redirectIfAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const isFirstUser = await authManager.isFirstUser();
|
||||
|
||||
if (!isFirstUser) {
|
||||
return res.status(403).render('error', {
|
||||
error: 'Registration Not Available',
|
||||
message: 'New accounts can only be created by existing administrators. Please contact an admin to create your account.'
|
||||
});
|
||||
}
|
||||
|
||||
res.render('auth/register', {
|
||||
title: 'Setup Administrator Account',
|
||||
isFirstUser: isFirstUser,
|
||||
currentPage: 'register'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking first user:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load registration page',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Process login
|
||||
router.post('/login', redirectIfAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { username, password, redirect } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.render('auth/login', {
|
||||
title: 'Login',
|
||||
error: 'Username and password are required',
|
||||
redirectUrl: redirect || '/',
|
||||
currentPage: 'login',
|
||||
formData: { username }
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authManager.authenticateUser(username, password);
|
||||
|
||||
// Log successful authentication
|
||||
await securityLogger.logAuthSuccess(req, username);
|
||||
|
||||
// Create session
|
||||
req.session.user = user;
|
||||
|
||||
// Redirect to intended page or dashboard
|
||||
const redirectUrl = redirect && redirect !== '/login' ? redirect : '/';
|
||||
res.redirect(redirectUrl);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
// Log failed authentication
|
||||
await securityLogger.logAuthFailure(req, username, error.message);
|
||||
|
||||
res.render('auth/login', {
|
||||
title: 'Login',
|
||||
error: error.message,
|
||||
redirectUrl: req.body.redirect || '/',
|
||||
currentPage: 'login',
|
||||
formData: { username: req.body.username }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Process registration (only for first user)
|
||||
router.post('/register', redirectIfAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const isFirstUser = await authManager.isFirstUser();
|
||||
|
||||
if (!isFirstUser) {
|
||||
return res.status(403).render('error', {
|
||||
error: 'Registration Not Available',
|
||||
message: 'New accounts can only be created by existing administrators.'
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!username || !password || !confirmPassword) {
|
||||
return res.render('auth/register', {
|
||||
title: 'Setup Administrator Account',
|
||||
error: 'All fields are required',
|
||||
isFirstUser: true,
|
||||
currentPage: 'register',
|
||||
formData: { username }
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return res.render('auth/register', {
|
||||
title: 'Setup Administrator Account',
|
||||
error: 'Passwords do not match',
|
||||
isFirstUser: true,
|
||||
currentPage: 'register',
|
||||
formData: { username }
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authManager.createUser(username, password);
|
||||
|
||||
// Create session for new user
|
||||
req.session.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
created_at: user.created_at
|
||||
};
|
||||
|
||||
// Redirect to dashboard
|
||||
res.redirect('/?registered=true');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
|
||||
res.render('auth/register', {
|
||||
title: 'Register',
|
||||
error: error.message,
|
||||
isFirstUser: await authManager.isFirstUser(),
|
||||
currentPage: 'register',
|
||||
formData: {
|
||||
username: req.body.username
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} else {
|
||||
res.redirect('/login?message=You have been logged out');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Get logout (for convenience)
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Logout error:', err);
|
||||
}
|
||||
res.redirect('/login?message=You have been logged out');
|
||||
});
|
||||
});
|
||||
|
||||
// User profile page
|
||||
router.get('/profile', async (req, res) => {
|
||||
if (!req.session || !req.session.user) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await authManager.getUserById(req.session.user.id);
|
||||
|
||||
if (!user) {
|
||||
req.session.destroy();
|
||||
return res.redirect('/login?error=User not found');
|
||||
}
|
||||
|
||||
res.render('auth/profile', {
|
||||
title: 'Profile',
|
||||
user: user,
|
||||
currentPage: 'profile'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Profile error:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load profile',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', async (req, res) => {
|
||||
if (!req.session || !req.session.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { currentPassword, newPassword, confirmPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error('New passwords do not match');
|
||||
}
|
||||
|
||||
await authManager.changePassword(req.session.user.id, currentPassword, newPassword);
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} else {
|
||||
res.redirect('/profile?success=Password changed successfully');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
} else {
|
||||
res.redirect('/profile?error=' + encodeURIComponent(error.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
316
routes/config.js
Normal file
316
routes/config.js
Normal file
@@ -0,0 +1,316 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
const appConfig = require('../utils/app-config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configuration schema
|
||||
const configSchema = {
|
||||
system: {
|
||||
data_directory: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Luanti data directory path (leave empty for auto-detection)',
|
||||
section: 'System Settings'
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: {
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
description: 'Port for server to listen on'
|
||||
},
|
||||
server_name: {
|
||||
type: 'string',
|
||||
default: 'Luanti Server',
|
||||
description: 'Name of the server'
|
||||
},
|
||||
server_description: {
|
||||
type: 'string',
|
||||
default: 'A Luanti server',
|
||||
description: 'Server description'
|
||||
},
|
||||
server_address: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'IP address to bind to (empty for all interfaces)'
|
||||
},
|
||||
server_announce: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Announce server to server list'
|
||||
},
|
||||
server_dedicated: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run as dedicated server'
|
||||
},
|
||||
max_users: {
|
||||
type: 'number',
|
||||
default: 20,
|
||||
description: 'Maximum number of users'
|
||||
}
|
||||
},
|
||||
gameplay: {
|
||||
creative_mode: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable creative mode by default'
|
||||
},
|
||||
enable_damage: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player damage by default'
|
||||
},
|
||||
enable_pvp: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player vs player combat by default'
|
||||
},
|
||||
default_game: {
|
||||
type: 'string',
|
||||
default: 'minetest_game',
|
||||
description: 'Default game to use for new worlds'
|
||||
},
|
||||
time_speed: {
|
||||
type: 'number',
|
||||
default: 72,
|
||||
description: 'Time speed (72 = normal, higher = faster)'
|
||||
}
|
||||
},
|
||||
security: {
|
||||
disallow_empty_password: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Disallow empty passwords'
|
||||
},
|
||||
'secure.enable_security': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable security features'
|
||||
},
|
||||
strict_protocol_version_checking: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Strict protocol version checking'
|
||||
}
|
||||
},
|
||||
performance: {
|
||||
dedicated_server_step: {
|
||||
type: 'number',
|
||||
default: 0.1,
|
||||
description: 'Server step time in seconds'
|
||||
},
|
||||
num_emerge_threads: {
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description: 'Number of emerge threads'
|
||||
},
|
||||
server_map_save_interval: {
|
||||
type: 'number',
|
||||
default: 15.3,
|
||||
description: 'Map save interval in seconds'
|
||||
},
|
||||
max_block_send_distance: {
|
||||
type: 'number',
|
||||
default: 12,
|
||||
description: 'Maximum block send distance'
|
||||
},
|
||||
max_simultaneous_block_sends_per_client: {
|
||||
type: 'number',
|
||||
default: 40,
|
||||
description: 'Maximum simultaneous block sends per client'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Configuration page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Load both Luanti config and app config
|
||||
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
|
||||
await appConfig.load();
|
||||
|
||||
// Combine configs for display
|
||||
const combinedConfig = {
|
||||
...luantiConfig,
|
||||
data_directory: appConfig.getDataDirectory()
|
||||
};
|
||||
|
||||
res.render('config/index', {
|
||||
title: 'Server Configuration',
|
||||
config: combinedConfig,
|
||||
schema: configSchema,
|
||||
currentPage: 'config',
|
||||
currentDataDirectory: appConfig.getDataDirectory(),
|
||||
defaultDataDirectory: appConfig.getDefaultDataDirectory()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load configuration',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update configuration
|
||||
router.post('/update', async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
// Handle data directory change separately
|
||||
if (updates.data_directory !== undefined) {
|
||||
const newDataDir = updates.data_directory.trim();
|
||||
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
|
||||
try {
|
||||
await appConfig.setDataDirectory(newDataDir);
|
||||
// Update paths to use new directory
|
||||
await paths.initialize();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update data directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
delete updates.data_directory; // Remove from Luanti config updates
|
||||
}
|
||||
|
||||
// Read current Luanti config
|
||||
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
|
||||
|
||||
// Process form data and convert types for Luanti config
|
||||
const processedUpdates = {};
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key === '_csrf' || key === 'returnUrl') continue; // Skip CSRF and utility fields
|
||||
|
||||
// Find the field in schema to determine type
|
||||
let fieldType = 'string';
|
||||
let fieldFound = false;
|
||||
|
||||
for (const section of Object.values(configSchema)) {
|
||||
if (section[key]) {
|
||||
fieldType = section[key].type;
|
||||
fieldFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert value based on type
|
||||
if (fieldType === 'boolean') {
|
||||
processedUpdates[key] = value === 'on' || value === 'true';
|
||||
} else if (fieldType === 'number') {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
processedUpdates[key] = numValue;
|
||||
}
|
||||
} else {
|
||||
// String or unknown type
|
||||
if (value !== '') {
|
||||
processedUpdates[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with current Luanti config
|
||||
const updatedConfig = { ...currentConfig, ...processedUpdates };
|
||||
|
||||
// Write updated Luanti config
|
||||
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
|
||||
|
||||
const returnUrl = req.body.returnUrl || '/config';
|
||||
res.redirect(`${returnUrl}?updated=true`);
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
|
||||
const returnUrl = req.body.returnUrl || '/config';
|
||||
res.redirect(`${returnUrl}?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset configuration to defaults
|
||||
router.post('/reset', async (req, res) => {
|
||||
try {
|
||||
const defaultConfig = {};
|
||||
|
||||
// Build default configuration from schema
|
||||
for (const [sectionName, section] of Object.entries(configSchema)) {
|
||||
for (const [key, field] of Object.entries(section)) {
|
||||
if (field.default !== undefined) {
|
||||
defaultConfig[key] = field.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ConfigParser.writeConfig(paths.configFile, defaultConfig);
|
||||
|
||||
res.redirect('/config?reset=true');
|
||||
} catch (error) {
|
||||
console.error('Error resetting config:', error);
|
||||
res.redirect(`/config?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Export current configuration as file
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const config = await ConfigParser.parseConfig(paths.configFile);
|
||||
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=minetest.conf');
|
||||
|
||||
const configLines = [];
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
configLines.push(`${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.send(configLines.join('\n'));
|
||||
} catch (error) {
|
||||
console.error('Error exporting config:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to export configuration',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get configuration schema (API)
|
||||
router.get('/api/schema', (req, res) => {
|
||||
res.json(configSchema);
|
||||
});
|
||||
|
||||
// Get current configuration (API)
|
||||
router.get('/api/current', async (req, res) => {
|
||||
try {
|
||||
const config = await ConfigParser.parseConfig(paths.configFile);
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
res.status(500).json({ error: 'Failed to get configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update configuration (API)
|
||||
router.put('/api/update', async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
|
||||
const updatedConfig = { ...currentConfig, ...updates };
|
||||
|
||||
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
|
||||
|
||||
res.json({ message: 'Configuration updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
res.status(500).json({ error: 'Failed to update configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
529
routes/contentdb.js
Normal file
529
routes/contentdb.js
Normal file
@@ -0,0 +1,529 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ContentDBClient = require('../utils/contentdb');
|
||||
const ContentDBUrlParser = require('../utils/contentdb-url');
|
||||
const PackageRegistry = require('../utils/package-registry');
|
||||
|
||||
const router = express.Router();
|
||||
const contentdb = new ContentDBClient();
|
||||
const packageRegistry = new PackageRegistry();
|
||||
|
||||
// Initialize package registry
|
||||
packageRegistry.init().catch(console.error);
|
||||
|
||||
// ContentDB browse page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
q = '',
|
||||
type = '',
|
||||
sort = 'score',
|
||||
order = 'desc',
|
||||
page = '1'
|
||||
} = req.query;
|
||||
|
||||
const limit = 20;
|
||||
const offset = (parseInt(page) - 1) * limit;
|
||||
|
||||
const packages = await contentdb.searchPackages(q, type, sort, order, limit, offset);
|
||||
|
||||
const totalPages = Math.ceil((packages.length || 0) / limit);
|
||||
const currentPage = parseInt(page);
|
||||
|
||||
res.render('contentdb/index', {
|
||||
title: 'ContentDB Browser',
|
||||
packages: packages || [],
|
||||
search: {
|
||||
query: q,
|
||||
type: type,
|
||||
sort: sort,
|
||||
order: order
|
||||
},
|
||||
pagination: {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
hasNext: currentPage < totalPages,
|
||||
hasPrev: currentPage > 1
|
||||
},
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error browsing ContentDB:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to browse ContentDB',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Popular packages
|
||||
router.get('/popular', async (req, res) => {
|
||||
try {
|
||||
const type = req.query.type || '';
|
||||
const packages = await contentdb.getPopularPackages(type, 20);
|
||||
|
||||
res.render('contentdb/popular', {
|
||||
title: 'Popular Content',
|
||||
packages: packages || [],
|
||||
type: type,
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting popular packages:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load popular content',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recent packages
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const type = req.query.type || '';
|
||||
const packages = await contentdb.getRecentPackages(type, 20);
|
||||
|
||||
res.render('contentdb/recent', {
|
||||
title: 'Recent Content',
|
||||
packages: packages || [],
|
||||
type: type,
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting recent packages:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load recent content',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Package details
|
||||
router.get('/package/:author/:name', async (req, res) => {
|
||||
try {
|
||||
const { author, name } = req.params;
|
||||
|
||||
const [packageInfo, releases] = await Promise.all([
|
||||
contentdb.getPackage(author, name),
|
||||
contentdb.getPackageReleases(author, name)
|
||||
]);
|
||||
|
||||
let dependencies = null;
|
||||
try {
|
||||
dependencies = await contentdb.getPackageDependencies(author, name);
|
||||
} catch (depError) {
|
||||
console.warn('Could not get dependencies:', depError.message);
|
||||
}
|
||||
|
||||
res.render('contentdb/package', {
|
||||
title: `${packageInfo.title || packageInfo.name}`,
|
||||
package: packageInfo,
|
||||
releases: releases || [],
|
||||
dependencies: dependencies,
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).render('error', {
|
||||
error: 'Package not found',
|
||||
message: 'The requested package could not be found on ContentDB.'
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Error getting package details:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load package details',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Install package
|
||||
router.post('/install/:author/:name', async (req, res) => {
|
||||
try {
|
||||
const { author, name } = req.params;
|
||||
const { version, installDeps = false } = req.body;
|
||||
|
||||
// Get package info to determine type
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
const packageType = packageInfo.type || 'mod';
|
||||
|
||||
// Determine target path based on package type
|
||||
let targetPath;
|
||||
let locationDescription;
|
||||
|
||||
if (packageType === 'game') {
|
||||
// VALIDATION: Games always go to games directory - cannot be installed to worlds
|
||||
// This prevents user confusion and maintains proper Luanti architecture where:
|
||||
// - Games are global and shared across all worlds
|
||||
// - Worlds are created with a specific game and cannot change games later
|
||||
// - Installing a game to a world would break the world or have no effect
|
||||
if (req.body.installTo === 'world') {
|
||||
return res.status(400).json({
|
||||
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
|
||||
type: 'invalid_installation_target',
|
||||
packageType: 'game'
|
||||
});
|
||||
}
|
||||
targetPath = paths.getGamePath(name);
|
||||
locationDescription = 'games directory';
|
||||
} else if (packageType === 'txp') {
|
||||
// Texture packs go to textures directory
|
||||
targetPath = path.join(paths.texturesDir, name);
|
||||
locationDescription = 'textures directory';
|
||||
} else {
|
||||
// Mods can go to global or world-specific location
|
||||
if (req.body.installTo === 'world' && req.body.worldName) {
|
||||
if (!paths.isValidWorldName(req.body.worldName)) {
|
||||
return res.status(400).json({ error: 'Invalid world name' });
|
||||
}
|
||||
targetPath = path.join(paths.getWorldModsPath(req.body.worldName), name);
|
||||
locationDescription = `world "${req.body.worldName}"`;
|
||||
} else {
|
||||
targetPath = path.join(paths.modsDir, name);
|
||||
locationDescription = 'global directory';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return res.status(409).json({ error: 'Package already installed at this location' });
|
||||
} catch {}
|
||||
|
||||
let result;
|
||||
if (installDeps === 'on' && packageType === 'mod') {
|
||||
// Install with dependencies (only for mods)
|
||||
const basePath = req.body.installTo === 'world'
|
||||
? paths.getWorldModsPath(req.body.worldName)
|
||||
: paths.modsDir;
|
||||
result = await contentdb.installPackageWithDeps(author, name, basePath, true);
|
||||
} else {
|
||||
// Install just the package
|
||||
result = await contentdb.downloadPackage(author, name, targetPath, version);
|
||||
}
|
||||
|
||||
const location = packageType === 'game' ? 'games' :
|
||||
packageType === 'txp' ? 'textures' :
|
||||
(req.body.installTo === 'world' ? req.body.worldName : 'global');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Package ${name} installed successfully to ${location}`,
|
||||
result: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error installing package:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to install package: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for updates
|
||||
router.get('/updates', async (req, res) => {
|
||||
try {
|
||||
// Get installed packages from registry
|
||||
const installedPackages = await packageRegistry.getAllInstallations();
|
||||
const updates = [];
|
||||
|
||||
for (const pkg of installedPackages) {
|
||||
try {
|
||||
// Get latest release info from ContentDB
|
||||
const releases = await contentdb.getPackageReleases(pkg.author, pkg.name);
|
||||
|
||||
if (releases && releases.length > 0) {
|
||||
const latestRelease = releases[0];
|
||||
|
||||
// Simple version comparison - if release IDs differ, consider it an update
|
||||
const hasUpdate = pkg.release_id !== latestRelease.id;
|
||||
|
||||
if (hasUpdate) {
|
||||
const packageInfo = await contentdb.getPackage(pkg.author, pkg.name);
|
||||
updates.push({
|
||||
installed: pkg,
|
||||
latest: {
|
||||
package: packageInfo,
|
||||
release: latestRelease
|
||||
},
|
||||
hasUpdate: true
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not check updates for ${pkg.author}/${pkg.name}:`, error.message);
|
||||
// Skip packages that can't be checked
|
||||
}
|
||||
}
|
||||
|
||||
res.render('contentdb/updates', {
|
||||
title: 'Available Updates',
|
||||
updates: updates,
|
||||
installedCount: installedPackages.length,
|
||||
updateCount: updates.length,
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to check for updates',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// View installed packages
|
||||
router.get('/installed', async (req, res) => {
|
||||
try {
|
||||
const { location } = req.query;
|
||||
const packages = await packageRegistry.getInstalledPackages(location);
|
||||
const stats = await packageRegistry.getStatistics();
|
||||
|
||||
res.render('contentdb/installed', {
|
||||
title: 'Installed Packages',
|
||||
packages: packages,
|
||||
statistics: stats,
|
||||
selectedLocation: location || 'all',
|
||||
currentPage: 'contentdb'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting installed packages:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load installed packages',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Install package from URL
|
||||
router.post('/install-url', async (req, res) => {
|
||||
try {
|
||||
const { packageUrl, installLocation, worldName, installDeps } = req.body;
|
||||
|
||||
if (!packageUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Package URL is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate URL
|
||||
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
|
||||
if (!parsed.isValid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: parsed.error || 'Invalid URL format'
|
||||
});
|
||||
}
|
||||
|
||||
const { author, name } = parsed;
|
||||
|
||||
// Get package info to determine type
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
const packageType = packageInfo.type || 'mod';
|
||||
|
||||
// Determine target path based on package type
|
||||
let targetPath;
|
||||
let locationDescription;
|
||||
|
||||
if (packageType === 'game') {
|
||||
// VALIDATION: Games always go to games directory - cannot be installed to worlds
|
||||
// This prevents user confusion and maintains proper Luanti architecture where:
|
||||
// - Games are global and shared across all worlds
|
||||
// - Worlds are created with a specific game and cannot change games later
|
||||
// - Installing a game to a world would break the world or have no effect
|
||||
if (installLocation === 'world') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
|
||||
type: 'invalid_installation_target',
|
||||
packageType: 'game'
|
||||
});
|
||||
}
|
||||
await fs.mkdir(paths.gamesDir, { recursive: true });
|
||||
targetPath = paths.getGamePath(name);
|
||||
locationDescription = 'games directory';
|
||||
} else if (packageType === 'txp') {
|
||||
// Texture packs go to textures directory
|
||||
await fs.mkdir(paths.texturesDir, { recursive: true });
|
||||
targetPath = path.join(paths.texturesDir, name);
|
||||
locationDescription = 'textures directory';
|
||||
} else {
|
||||
// Mods can go to global or world-specific location
|
||||
if (installLocation === 'world') {
|
||||
if (!worldName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'World name is required when installing to specific world'
|
||||
});
|
||||
}
|
||||
|
||||
if (!paths.isValidWorldName(worldName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid world name'
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure worldmods directory exists
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
|
||||
targetPath = path.join(worldModsPath, name);
|
||||
locationDescription = `world "${worldName}"`;
|
||||
} else {
|
||||
// Global installation
|
||||
await fs.mkdir(paths.modsDir, { recursive: true });
|
||||
targetPath = path.join(paths.modsDir, name);
|
||||
locationDescription = 'global directory';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed at this location
|
||||
let installLocationKey;
|
||||
if (packageType === 'game') {
|
||||
installLocationKey = 'games';
|
||||
} else if (packageType === 'txp') {
|
||||
installLocationKey = 'textures';
|
||||
} else {
|
||||
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
|
||||
}
|
||||
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
|
||||
|
||||
if (isInstalled) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: `Package "${name}" is already installed in ${locationDescription}`
|
||||
});
|
||||
}
|
||||
|
||||
// Install the package
|
||||
let installResult;
|
||||
|
||||
if (installDeps === 'on' && packageType === 'mod') {
|
||||
// Install with dependencies (only for mods)
|
||||
const basePath = installLocation === 'world'
|
||||
? paths.getWorldModsPath(worldName)
|
||||
: paths.modsDir;
|
||||
|
||||
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
|
||||
|
||||
if (installResult.errors && installResult.errors.length > 0) {
|
||||
console.warn('Installation completed with errors:', installResult.errors);
|
||||
}
|
||||
} else {
|
||||
// Install just the main package
|
||||
installResult = await contentdb.downloadPackage(author, name, targetPath);
|
||||
}
|
||||
|
||||
// Record installation in registry
|
||||
try {
|
||||
// Handle different installResult structures
|
||||
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
|
||||
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: author,
|
||||
name: name,
|
||||
version: releaseInfo?.title || 'latest',
|
||||
releaseId: releaseInfo?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: targetPath,
|
||||
contentdbUrl: parsed.fullUrl,
|
||||
packageType: packageInfo?.type || 'mod',
|
||||
title: packageInfo?.title || name,
|
||||
shortDescription: packageInfo?.short_description || '',
|
||||
dependencies: packageInfo?.hard_dependencies || []
|
||||
});
|
||||
|
||||
// Record dependencies if installed
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
for (const dep of installResult.dependencies) {
|
||||
const depInfo = dep.package;
|
||||
const depRelease = dep.release;
|
||||
const depPath = path.join(
|
||||
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
|
||||
depInfo.name
|
||||
);
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: depInfo.author,
|
||||
name: depInfo.name,
|
||||
version: depRelease?.title || 'latest',
|
||||
releaseId: depRelease?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: depPath,
|
||||
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
|
||||
packageType: depInfo.type || 'mod',
|
||||
title: depInfo.title || depInfo.name,
|
||||
shortDescription: depInfo.short_description || '',
|
||||
dependencies: depInfo.hard_dependencies || []
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (registryError) {
|
||||
console.warn('Failed to record installation in registry:', registryError);
|
||||
// Continue anyway, installation was successful
|
||||
}
|
||||
|
||||
// Create success response
|
||||
let message = `Successfully installed "${name}" to ${locationDescription}`;
|
||||
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
const depCount = installResult.dependencies.length;
|
||||
if (depCount > 0) {
|
||||
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: message,
|
||||
package: {
|
||||
author: author,
|
||||
name: name,
|
||||
location: locationDescription
|
||||
},
|
||||
installResult: installResult
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing package from URL:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Installation failed: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// API endpoint for search (AJAX)
|
||||
router.get('/api/search', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
q = '',
|
||||
type = '',
|
||||
sort = 'score',
|
||||
order = 'desc',
|
||||
limit = '10'
|
||||
} = req.query;
|
||||
|
||||
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
|
||||
|
||||
res.json({
|
||||
packages: packages || [],
|
||||
query: q,
|
||||
type: type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching ContentDB:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
365
routes/extensions.js
Normal file
365
routes/extensions.js
Normal file
@@ -0,0 +1,365 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
const ContentDBClient = require('../utils/contentdb');
|
||||
const ContentDBUrlParser = require('../utils/contentdb-url');
|
||||
const PackageRegistry = require('../utils/package-registry');
|
||||
|
||||
const router = express.Router();
|
||||
const contentdb = new ContentDBClient();
|
||||
const packageRegistry = new PackageRegistry();
|
||||
|
||||
// Initialize package registry
|
||||
packageRegistry.init().catch(console.error);
|
||||
|
||||
// Main Extensions page - shows installed content and installer
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Get installed packages from registry (games, mods, texture packs)
|
||||
const allRegistryPackages = await packageRegistry.getAllInstallations();
|
||||
const statistics = await packageRegistry.getStatistics();
|
||||
|
||||
// Filter registry packages to only include those that actually exist on disk
|
||||
const installedPackages = [];
|
||||
for (const pkg of allRegistryPackages) {
|
||||
let packagePath;
|
||||
if (pkg.package_type === 'game') {
|
||||
packagePath = paths.getGamePath(pkg.name);
|
||||
} else if (pkg.package_type === 'mod') {
|
||||
packagePath = paths.getModPath(pkg.name);
|
||||
} else {
|
||||
// For other types, assume they exist (texture packs, etc.)
|
||||
installedPackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include if the package directory actually exists
|
||||
try {
|
||||
const stats = await fs.stat(packagePath);
|
||||
if (stats.isDirectory()) {
|
||||
installedPackages.push(pkg);
|
||||
}
|
||||
} catch (error) {
|
||||
// Package directory doesn't exist, don't include it
|
||||
console.log(`Package ${pkg.name} (${pkg.package_type}) not found at ${packagePath}, excluding from installed list`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get local mods (not from ContentDB)
|
||||
let localMods = [];
|
||||
try {
|
||||
const modDirs = await fs.readdir(paths.modsDir);
|
||||
|
||||
for (const modDir of modDirs) {
|
||||
try {
|
||||
const modPath = paths.getModPath(modDir);
|
||||
const configPath = paths.getModConfigPath(modDir);
|
||||
|
||||
const stats = await fs.stat(modPath);
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
// Check if this mod is already in the registry (from ContentDB)
|
||||
const isFromContentDB = installedPackages.some(pkg =>
|
||||
pkg.name === modDir && pkg.install_location === 'global'
|
||||
);
|
||||
|
||||
if (!isFromContentDB) {
|
||||
const config = await ConfigParser.parseModConfig(configPath);
|
||||
|
||||
localMods.push({
|
||||
name: modDir,
|
||||
title: config.title || modDir,
|
||||
description: config.description || '',
|
||||
author: config.author || 'Local',
|
||||
type: 'mod',
|
||||
location: 'global',
|
||||
source: 'local',
|
||||
path: modPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
}
|
||||
} catch (modError) {
|
||||
console.error(`Error reading mod ${modDir}:`, modError);
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.warn('Could not read mods directory:', dirError);
|
||||
}
|
||||
|
||||
// Get installed games from all locations (only those NOT already in ContentDB registry)
|
||||
let localGames = [];
|
||||
try {
|
||||
const allInstalledGames = await paths.getInstalledGames();
|
||||
|
||||
for (const game of allInstalledGames) {
|
||||
// Check if this game is already in the ContentDB registry
|
||||
const isFromContentDB = installedPackages.some(pkg =>
|
||||
(pkg.name === game.name || pkg.name === game.directoryName) && pkg.package_type === 'game'
|
||||
);
|
||||
|
||||
if (!isFromContentDB) {
|
||||
localGames.push({
|
||||
name: game.name,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
author: game.author || 'Unknown',
|
||||
type: 'game',
|
||||
location: 'games',
|
||||
source: game.isSystemGame ? 'system' : 'local',
|
||||
path: game.path,
|
||||
lastModified: null // We don't have this info from the paths util
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.warn('Could not read games:', dirError);
|
||||
}
|
||||
|
||||
// Combine all content (ContentDB packages already include games)
|
||||
const allContent = [
|
||||
...installedPackages.map(pkg => ({ ...pkg, source: 'contentdb' })),
|
||||
...localMods,
|
||||
...localGames
|
||||
];
|
||||
|
||||
// Sort by type (games first, then mods, then texture packs) and name
|
||||
const sortOrder = { game: 1, mod: 2, txp: 3 };
|
||||
allContent.sort((a, b) => {
|
||||
const typeA = sortOrder[a.package_type || a.type] || 4;
|
||||
const typeB = sortOrder[b.package_type || b.type] || 4;
|
||||
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
return (a.title || a.name).localeCompare(b.title || b.name);
|
||||
});
|
||||
|
||||
res.render('extensions/index', {
|
||||
title: 'Extensions',
|
||||
allContent: allContent,
|
||||
statistics: {
|
||||
...statistics,
|
||||
games: installedPackages.filter(pkg => pkg.package_type === 'game').length + localGames.length,
|
||||
local_mods: localMods.length
|
||||
},
|
||||
currentPage: 'extensions'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading extensions:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load extensions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Install package from URL (same as ContentDB)
|
||||
router.post('/install-url', async (req, res) => {
|
||||
try {
|
||||
const { packageUrl, installLocation, worldName, installDeps } = req.body;
|
||||
|
||||
if (!packageUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Package URL is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate URL
|
||||
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: parsed.error || 'Invalid URL format'
|
||||
});
|
||||
}
|
||||
|
||||
const { author, name } = parsed;
|
||||
|
||||
// Get package info to determine type
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
const packageType = packageInfo.type || 'mod';
|
||||
|
||||
// Determine target path based on package type
|
||||
let targetPath;
|
||||
let locationDescription;
|
||||
|
||||
if (packageType === 'game') {
|
||||
await fs.mkdir(paths.gamesDir, { recursive: true });
|
||||
targetPath = paths.getGamePath(name);
|
||||
locationDescription = 'games directory';
|
||||
} else if (packageType === 'txp') {
|
||||
await fs.mkdir(paths.texturesDir, { recursive: true });
|
||||
targetPath = path.join(paths.texturesDir, name);
|
||||
locationDescription = 'textures directory';
|
||||
} else {
|
||||
if (installLocation === 'world') {
|
||||
if (!worldName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'World name is required when installing to specific world'
|
||||
});
|
||||
}
|
||||
|
||||
if (!paths.isValidWorldName(worldName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid world name'
|
||||
});
|
||||
}
|
||||
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
|
||||
targetPath = path.join(worldModsPath, name);
|
||||
locationDescription = `world "${worldName}"`;
|
||||
} else {
|
||||
await fs.mkdir(paths.modsDir, { recursive: true });
|
||||
targetPath = path.join(paths.modsDir, name);
|
||||
locationDescription = 'global directory';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
let installLocationKey;
|
||||
if (packageType === 'game') {
|
||||
installLocationKey = 'games';
|
||||
} else if (packageType === 'txp') {
|
||||
installLocationKey = 'textures';
|
||||
} else {
|
||||
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
|
||||
}
|
||||
|
||||
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
|
||||
|
||||
if (isInstalled) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: `Package "${name}" is already installed in ${locationDescription}`
|
||||
});
|
||||
}
|
||||
|
||||
// Install the package
|
||||
let installResult;
|
||||
|
||||
if (installDeps === 'on' && packageType === 'mod') {
|
||||
const basePath = installLocation === 'world'
|
||||
? paths.getWorldModsPath(worldName)
|
||||
: paths.modsDir;
|
||||
|
||||
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
|
||||
|
||||
if (installResult.errors && installResult.errors.length > 0) {
|
||||
console.warn('Installation completed with errors:', installResult.errors);
|
||||
}
|
||||
} else {
|
||||
installResult = await contentdb.downloadPackage(author, name, targetPath);
|
||||
}
|
||||
|
||||
// Record installation in registry
|
||||
try {
|
||||
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
|
||||
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: author,
|
||||
name: name,
|
||||
version: releaseInfo?.title || 'latest',
|
||||
releaseId: releaseInfo?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: targetPath,
|
||||
contentdbUrl: parsed.fullUrl,
|
||||
packageType: packageInfo?.type || 'mod',
|
||||
title: packageInfo?.title || name,
|
||||
shortDescription: packageInfo?.short_description || '',
|
||||
dependencies: packageInfo?.hard_dependencies || []
|
||||
});
|
||||
|
||||
// Record dependencies if installed
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
for (const dep of installResult.dependencies) {
|
||||
const depInfo = dep.package;
|
||||
const depRelease = dep.release;
|
||||
const depPath = path.join(
|
||||
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
|
||||
depInfo.name
|
||||
);
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: depInfo.author,
|
||||
name: depInfo.name,
|
||||
version: depRelease?.title || 'latest',
|
||||
releaseId: depRelease?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: depPath,
|
||||
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
|
||||
packageType: depInfo.type || 'mod',
|
||||
title: depInfo.title || depInfo.name,
|
||||
shortDescription: depInfo.short_description || '',
|
||||
dependencies: depInfo.hard_dependencies || []
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (registryError) {
|
||||
console.warn('Failed to record installation in registry:', registryError);
|
||||
}
|
||||
|
||||
// Create success response
|
||||
let message = `Successfully installed "${name}" to ${locationDescription}`;
|
||||
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
const depCount = installResult.dependencies.length;
|
||||
if (depCount > 0) {
|
||||
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: message,
|
||||
package: {
|
||||
author: author,
|
||||
name: name,
|
||||
location: locationDescription
|
||||
},
|
||||
installResult: installResult
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing package from URL:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Installation failed: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint for search (AJAX)
|
||||
router.get('/api/search', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
q = '',
|
||||
type = '',
|
||||
sort = 'score',
|
||||
order = 'desc',
|
||||
limit = '10'
|
||||
} = req.query;
|
||||
|
||||
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
|
||||
|
||||
res.json({
|
||||
packages: packages || [],
|
||||
query: q,
|
||||
type: type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching ContentDB:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
318
routes/mods.js
Normal file
318
routes/mods.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Mods listing page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
let globalMods = [];
|
||||
let worlds = [];
|
||||
|
||||
// Get global mods
|
||||
try {
|
||||
const modDirs = await fs.readdir(paths.modsDir);
|
||||
|
||||
for (const modDir of modDirs) {
|
||||
try {
|
||||
const modPath = paths.getModPath(modDir);
|
||||
const configPath = paths.getModConfigPath(modDir);
|
||||
|
||||
const stats = await fs.stat(modPath);
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
const config = await ConfigParser.parseModConfig(configPath);
|
||||
|
||||
globalMods.push({
|
||||
name: modDir,
|
||||
title: config.title || modDir,
|
||||
description: config.description || '',
|
||||
author: config.author || '',
|
||||
depends: config.depends || [],
|
||||
optional_depends: config.optional_depends || [],
|
||||
min_minetest_version: config.min_minetest_version || '',
|
||||
max_minetest_version: config.max_minetest_version || '',
|
||||
location: 'global',
|
||||
path: modPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
} catch (modError) {
|
||||
console.error(`Error reading mod ${modDir}:`, modError);
|
||||
}
|
||||
}
|
||||
} catch (dirError) {}
|
||||
|
||||
// Get worlds for dropdown
|
||||
try {
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldPath = paths.getWorldPath(worldDir);
|
||||
const configPath = paths.getWorldConfigPath(worldDir);
|
||||
const stats = await fs.stat(worldPath);
|
||||
if (stats.isDirectory()) {
|
||||
const config = await ConfigParser.parseWorldConfig(configPath);
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: config.server_name || worldDir
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const selectedWorld = req.query.world;
|
||||
let worldMods = [];
|
||||
|
||||
if (selectedWorld && paths.isValidWorldName(selectedWorld)) {
|
||||
try {
|
||||
const worldModsPath = paths.getWorldModsPath(selectedWorld);
|
||||
const modDirs = await fs.readdir(worldModsPath);
|
||||
|
||||
for (const modDir of modDirs) {
|
||||
try {
|
||||
const modPath = path.join(worldModsPath, modDir);
|
||||
const configPath = path.join(modPath, 'mod.conf');
|
||||
|
||||
const stats = await fs.stat(modPath);
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
const config = await ConfigParser.parseModConfig(configPath);
|
||||
|
||||
worldMods.push({
|
||||
name: modDir,
|
||||
title: config.title || modDir,
|
||||
description: config.description || '',
|
||||
author: config.author || '',
|
||||
depends: config.depends || [],
|
||||
optional_depends: config.optional_depends || [],
|
||||
location: 'world',
|
||||
enabled: true,
|
||||
path: modPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
} catch (modError) {
|
||||
console.error(`Error reading world mod ${modDir}:`, modError);
|
||||
}
|
||||
}
|
||||
} catch (dirError) {}
|
||||
}
|
||||
|
||||
res.render('mods/index', {
|
||||
title: 'Mod Management',
|
||||
globalMods: globalMods,
|
||||
worldMods: worldMods,
|
||||
worlds: worlds,
|
||||
selectedWorld: selectedWorld,
|
||||
currentPage: 'mods'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting mods:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load mods',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Install mod to world
|
||||
router.post('/install/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
const globalModPath = paths.getModPath(modName);
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
const targetModPath = path.join(worldModsPath, modName);
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'World not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(globalModPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Mod not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(targetModPath);
|
||||
return res.status(409).json({ error: 'Mod already installed in world' });
|
||||
} catch {}
|
||||
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
await fs.cp(globalModPath, targetModPath, { recursive: true });
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&installed=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error installing mod to world:', error);
|
||||
res.status(500).json({ error: 'Failed to install mod to world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove mod from world
|
||||
router.post('/remove/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
const modPath = path.join(worldModsPath, modName);
|
||||
|
||||
try {
|
||||
await fs.access(modPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Mod not found in world' });
|
||||
}
|
||||
|
||||
await fs.rm(modPath, { recursive: true, force: true });
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&removed=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error removing mod from world:', error);
|
||||
res.status(500).json({ error: 'Failed to remove mod from world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete global mod
|
||||
router.post('/delete/:modName', async (req, res) => {
|
||||
try {
|
||||
const { modName } = req.params;
|
||||
|
||||
if (!paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid mod name' });
|
||||
}
|
||||
|
||||
const modPath = paths.getModPath(modName);
|
||||
|
||||
try {
|
||||
await fs.access(modPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Mod not found' });
|
||||
}
|
||||
|
||||
await fs.rm(modPath, { recursive: true, force: true });
|
||||
|
||||
res.redirect(`/mods?deleted=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error deleting mod:', error);
|
||||
res.status(500).json({ error: 'Failed to delete mod' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mod details page
|
||||
router.get('/:modName', async (req, res) => {
|
||||
try {
|
||||
const { modName } = req.params;
|
||||
|
||||
if (!paths.isValidModName(modName)) {
|
||||
return res.status(400).render('error', {
|
||||
error: 'Invalid mod name'
|
||||
});
|
||||
}
|
||||
|
||||
const modPath = paths.getModPath(modName);
|
||||
const configPath = paths.getModConfigPath(modName);
|
||||
|
||||
try {
|
||||
await fs.access(modPath);
|
||||
} catch {
|
||||
return res.status(404).render('error', {
|
||||
error: 'Mod not found'
|
||||
});
|
||||
}
|
||||
|
||||
const config = await ConfigParser.parseModConfig(configPath);
|
||||
const stats = await fs.stat(modPath);
|
||||
|
||||
// Get mod files info
|
||||
let fileCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
async function countFiles(dirPath) {
|
||||
try {
|
||||
const items = await fs.readdir(dirPath);
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
const itemStats = await fs.stat(itemPath);
|
||||
if (itemStats.isDirectory()) {
|
||||
await countFiles(itemPath);
|
||||
} else {
|
||||
fileCount++;
|
||||
totalSize += itemStats.size;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await countFiles(modPath);
|
||||
|
||||
// Get worlds where this mod is installed
|
||||
const installedWorlds = [];
|
||||
try {
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldModPath = path.join(paths.getWorldModsPath(worldDir), modName);
|
||||
await fs.access(worldModPath);
|
||||
|
||||
const worldConfigPath = paths.getWorldConfigPath(worldDir);
|
||||
const worldConfig = await ConfigParser.parseWorldConfig(worldConfigPath);
|
||||
|
||||
installedWorlds.push({
|
||||
name: worldDir,
|
||||
displayName: worldConfig.server_name || worldDir
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const modDetails = {
|
||||
name: modName,
|
||||
title: config.title || modName,
|
||||
description: config.description || '',
|
||||
author: config.author || '',
|
||||
depends: config.depends || [],
|
||||
optional_depends: config.optional_depends || [],
|
||||
min_minetest_version: config.min_minetest_version || '',
|
||||
max_minetest_version: config.max_minetest_version || '',
|
||||
location: 'global',
|
||||
path: modPath,
|
||||
fileCount,
|
||||
totalSize,
|
||||
created: stats.birthtime,
|
||||
lastModified: stats.mtime,
|
||||
installedWorlds: installedWorlds,
|
||||
config: config
|
||||
};
|
||||
|
||||
res.render('mods/details', {
|
||||
title: `Mod: ${modDetails.title}`,
|
||||
mod: modDetails,
|
||||
currentPage: 'mods'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting mod details:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load mod details',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
473
routes/server.js
Normal file
473
routes/server.js
Normal file
@@ -0,0 +1,473 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const { spawn } = require('child_process');
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Security function to validate configuration overrides
|
||||
function validateConfigOverrides(configOverrides) {
|
||||
if (!configOverrides || typeof configOverrides !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitized = {};
|
||||
|
||||
// Whitelist of allowed configuration parameters
|
||||
const allowedConfigKeys = [
|
||||
'port', 'bind', 'name', 'motd', 'max_users', 'password', 'default_game',
|
||||
'enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
|
||||
'server_announce', 'serverlist_url', 'enable_pvp', 'time_speed', 'day_night_ratio',
|
||||
'max_simultaneous_block_sends_per_client', 'max_block_send_distance',
|
||||
'max_block_generate_distance', 'secure', 'enable_client_modding', 'csm_restriction_flags',
|
||||
'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration',
|
||||
'dedicated_server_step', 'ignore_world_load_errors', 'remote_media'
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(configOverrides)) {
|
||||
// Validate key
|
||||
if (!allowedConfigKeys.includes(key) || !/^[a-z_]+$/.test(key)) {
|
||||
continue; // Skip invalid keys
|
||||
}
|
||||
|
||||
// Validate and sanitize value
|
||||
let sanitizedValue = String(value).trim();
|
||||
|
||||
// Remove control characters
|
||||
sanitizedValue = sanitizedValue.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitizedValue.length > 200) {
|
||||
continue; // Skip overly long values
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (['port', 'max_users', 'time_speed', 'max_simultaneous_block_sends_per_client',
|
||||
'max_block_send_distance', 'max_block_generate_distance', 'csm_restriction_noderange',
|
||||
'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step'].includes(key)) {
|
||||
const numValue = parseInt(sanitizedValue, 10);
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 65535) {
|
||||
sanitized[key] = numValue.toString();
|
||||
}
|
||||
} else if (['enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
|
||||
'server_announce', 'enable_pvp', 'secure', 'enable_client_modding', 'ignore_world_load_errors'].includes(key)) {
|
||||
if (['true', 'false'].includes(sanitizedValue.toLowerCase())) {
|
||||
sanitized[key] = sanitizedValue.toLowerCase();
|
||||
}
|
||||
} else if (['bind', 'name', 'motd', 'password', 'default_game', 'serverlist_url'].includes(key)) {
|
||||
// String values - ensure they don't contain shell metacharacters
|
||||
if (!/[;&|`$(){}[\]<>\\]/.test(sanitizedValue)) {
|
||||
sanitized[key] = sanitizedValue;
|
||||
}
|
||||
} else {
|
||||
// Floating point values
|
||||
const floatValue = parseFloat(sanitizedValue);
|
||||
if (!isNaN(floatValue) && isFinite(floatValue)) {
|
||||
sanitized[key] = floatValue.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Global server state
|
||||
let serverProcess = null;
|
||||
let serverStatus = 'stopped';
|
||||
let serverLogs = [];
|
||||
let logWatcher = null;
|
||||
|
||||
// Server management page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Get available worlds for dropdown
|
||||
let worlds = [];
|
||||
try {
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldPath = paths.getWorldPath(worldDir);
|
||||
const configPath = paths.getWorldConfigPath(worldDir);
|
||||
const stats = await fs.stat(worldPath);
|
||||
if (stats.isDirectory()) {
|
||||
const config = await ConfigParser.parseWorldConfig(configPath);
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: config.server_name || worldDir
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Get recent logs
|
||||
let recentLogs = [];
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n').filter(line => line.trim());
|
||||
recentLogs = lines.slice(-50); // Last 50 lines
|
||||
} catch {
|
||||
// Debug file might not exist
|
||||
}
|
||||
|
||||
const serverInfo = {
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0,
|
||||
logs: [...recentLogs, ...serverLogs.map(log => log.message || log)].slice(-100)
|
||||
};
|
||||
|
||||
res.render('server/index', {
|
||||
title: 'Server Management',
|
||||
server: serverInfo,
|
||||
worlds: worlds,
|
||||
currentPage: 'server',
|
||||
scripts: ['server.js']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading server page:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load server management',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get server status (API)
|
||||
router.get('/api/status', (req, res) => {
|
||||
res.json({
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
|
||||
});
|
||||
});
|
||||
|
||||
// Get server logs (API)
|
||||
router.get('/api/logs', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
let fileLogs = [];
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n').filter(line => line.trim());
|
||||
fileLogs = lines.slice(-1000);
|
||||
} catch {}
|
||||
|
||||
const allLogs = [...fileLogs, ...serverLogs.map(log => log.message || log)];
|
||||
const paginatedLogs = allLogs.slice(offset, offset + limit);
|
||||
|
||||
res.json({
|
||||
logs: paginatedLogs,
|
||||
total: allLogs.length,
|
||||
offset,
|
||||
limit
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error);
|
||||
res.status(500).json({ error: 'Failed to get logs' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
router.post('/start', async (req, res) => {
|
||||
try {
|
||||
if (serverProcess && serverStatus === 'running') {
|
||||
return res.status(409).json({ error: 'Server is already running' });
|
||||
}
|
||||
|
||||
const { worldName, configOverrides } = req.body;
|
||||
|
||||
if (!worldName || !paths.isValidWorldName(worldName)) {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(400).json({ error: 'Valid world name required' });
|
||||
} else {
|
||||
return res.redirect('/server?error=Valid+world+name+required');
|
||||
}
|
||||
}
|
||||
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(404).json({ error: 'World not found' });
|
||||
} else {
|
||||
return res.redirect('/server?error=World+not+found');
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
'--server',
|
||||
'--world', worldPath,
|
||||
'--logfile', paths.debugFile
|
||||
];
|
||||
|
||||
if (configOverrides) {
|
||||
const sanitizedOverrides = validateConfigOverrides(configOverrides);
|
||||
for (const [key, value] of Object.entries(sanitizedOverrides)) {
|
||||
args.push(`--${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess = spawn('luanti', args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
serverProcess.startTime = Date.now();
|
||||
serverStatus = 'starting';
|
||||
serverLogs = [];
|
||||
|
||||
// Get Socket.IO instance from main app
|
||||
const { io } = require('../app');
|
||||
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
const logLine = data.toString().trim();
|
||||
if (logLine) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
const logLine = data.toString().trim();
|
||||
if (logLine) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: logLine
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('spawn', () => {
|
||||
serverStatus = 'running';
|
||||
console.log('Luanti server started');
|
||||
|
||||
if (io) {
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: serverProcess.pid,
|
||||
uptime: Date.now() - serverProcess.startTime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('error', (error) => {
|
||||
console.error('Server error:', error);
|
||||
serverStatus = 'error';
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: `Server error: ${error.message}`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: null,
|
||||
uptime: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('exit', (code, signal) => {
|
||||
console.log(`Server exited with code ${code}, signal ${signal}`);
|
||||
serverStatus = 'stopped';
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: `Server stopped (code: ${code}, signal: ${signal})`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
serverProcess = null;
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: null,
|
||||
uptime: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch debug log file
|
||||
if (logWatcher) {
|
||||
logWatcher.close();
|
||||
}
|
||||
|
||||
logWatcher = chokidar.watch(paths.debugFile, { persistent: true });
|
||||
logWatcher.on('change', async () => {
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n');
|
||||
const newLines = lines.slice(-10);
|
||||
|
||||
for (const line of newLines) {
|
||||
if (line.trim() && !serverLogs.some(log => (log.message || log) === line.trim())) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: line.trim()
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Server starting', pid: serverProcess.pid });
|
||||
} else {
|
||||
res.redirect('/server?started=true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting server:', error);
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(500).json({ error: 'Failed to start server' });
|
||||
} else {
|
||||
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stop server
|
||||
router.post('/stop', (req, res) => {
|
||||
try {
|
||||
if (!serverProcess || serverStatus !== 'running') {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(409).json({ error: 'Server is not running' });
|
||||
} else {
|
||||
return res.redirect('/server?error=Server+is+not+running');
|
||||
}
|
||||
}
|
||||
|
||||
serverStatus = 'stopping';
|
||||
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
setTimeout(() => {
|
||||
if (serverProcess && serverStatus === 'stopping') {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
if (logWatcher) {
|
||||
logWatcher.close();
|
||||
logWatcher = null;
|
||||
}
|
||||
|
||||
const { io } = require('../app');
|
||||
if (io) {
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
|
||||
});
|
||||
}
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Server stopping' });
|
||||
} else {
|
||||
res.redirect('/server?stopped=true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping server:', error);
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(500).json({ error: 'Failed to stop server' });
|
||||
} else {
|
||||
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send command to server
|
||||
router.post('/command', (req, res) => {
|
||||
try {
|
||||
if (!serverProcess || serverStatus !== 'running') {
|
||||
return res.status(409).json({ error: 'Server is not running' });
|
||||
}
|
||||
|
||||
const { command } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command required' });
|
||||
}
|
||||
|
||||
// Validate and sanitize the command using ServerManager's validation
|
||||
const ServerManager = require('../utils/server-manager');
|
||||
const serverManager = new ServerManager();
|
||||
|
||||
try {
|
||||
const sanitizedCommand = serverManager.validateServerCommand(command);
|
||||
serverProcess.stdin.write(sanitizedCommand + '\n');
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'command',
|
||||
message: `> ${sanitizedCommand}`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
const { io } = require('../app');
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
|
||||
res.json({ message: 'Command sent successfully' });
|
||||
} catch (validationError) {
|
||||
return res.status(400).json({ error: validationError.message });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error);
|
||||
res.status(500).json({ error: 'Failed to send command' });
|
||||
}
|
||||
});
|
||||
|
||||
// Export server state for use in main app
|
||||
router.getServerState = () => ({
|
||||
process: serverProcess,
|
||||
status: serverStatus,
|
||||
logs: serverLogs
|
||||
});
|
||||
|
||||
module.exports = router;
|
118
routes/users.js
Normal file
118
routes/users.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const express = require('express');
|
||||
const AuthManager = require('../utils/auth');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = new AuthManager();
|
||||
|
||||
// Initialize auth manager
|
||||
authManager.initialize().catch(console.error);
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// User management page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await authManager.getAllUsers();
|
||||
|
||||
res.render('users/index', {
|
||||
title: 'User Management',
|
||||
users: users,
|
||||
currentPage: 'users'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting users:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load users',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create new user page
|
||||
router.get('/new', (req, res) => {
|
||||
res.render('users/new', {
|
||||
title: 'Create New User',
|
||||
currentPage: 'users'
|
||||
});
|
||||
});
|
||||
|
||||
// Process user creation
|
||||
router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
const createdByUserId = req.session.user.id;
|
||||
|
||||
// Validate inputs
|
||||
if (!username || !password || !confirmPassword) {
|
||||
return res.render('users/new', {
|
||||
title: 'Create New User',
|
||||
error: 'All fields are required',
|
||||
currentPage: 'users',
|
||||
formData: { username }
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return res.render('users/new', {
|
||||
title: 'Create New User',
|
||||
error: 'Passwords do not match',
|
||||
currentPage: 'users',
|
||||
formData: { username }
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authManager.createUser(username, password, createdByUserId);
|
||||
|
||||
res.redirect('/users?created=' + encodeURIComponent(username));
|
||||
|
||||
} catch (error) {
|
||||
console.error('User creation error:', error);
|
||||
|
||||
res.render('users/new', {
|
||||
title: 'Create New User',
|
||||
error: error.message,
|
||||
currentPage: 'users',
|
||||
formData: {
|
||||
username: req.body.username
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.post('/delete/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const currentUserId = req.session.user.id;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (parseInt(userId) === currentUserId) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
const deleted = await authManager.deleteUser(userId);
|
||||
|
||||
if (deleted) {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'User deleted successfully' });
|
||||
} else {
|
||||
res.redirect('/users?deleted=true');
|
||||
}
|
||||
} else {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
} else {
|
||||
res.redirect('/users?error=' + encodeURIComponent(error.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
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;
|
127
utils/app-config.js
Normal file
127
utils/app-config.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class AppConfig {
|
||||
constructor() {
|
||||
this.configDir = path.join(os.homedir(), '.luhost');
|
||||
this.configFile = path.join(this.configDir, 'config.json');
|
||||
this.defaultConfig = {
|
||||
dataDirectory: this.getDefaultDataDirectory(),
|
||||
serverPort: 3000,
|
||||
debugMode: false
|
||||
};
|
||||
this.config = null;
|
||||
}
|
||||
|
||||
getDefaultDataDirectory() {
|
||||
const homeDir = os.homedir();
|
||||
const possibleDirs = [
|
||||
path.join(homeDir, '.luanti'),
|
||||
path.join(homeDir, '.minetest')
|
||||
];
|
||||
|
||||
// Use the first one that exists, or default to .minetest
|
||||
for (const dir of possibleDirs) {
|
||||
if (fsSync.existsSync(dir)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(homeDir, '.minetest');
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
// Ensure config directory exists
|
||||
if (!fsSync.existsSync(this.configDir)) {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Try to read existing config
|
||||
try {
|
||||
const configData = await fs.readFile(this.configFile, 'utf8');
|
||||
this.config = { ...this.defaultConfig, ...JSON.parse(configData) };
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Config file doesn't exist, create it with defaults
|
||||
this.config = { ...this.defaultConfig };
|
||||
await this.save();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.config;
|
||||
} catch (error) {
|
||||
console.error('Failed to load app config:', error);
|
||||
// Fall back to defaults if config loading fails
|
||||
this.config = { ...this.defaultConfig };
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
try {
|
||||
if (!fsSync.existsSync(this.configDir)) {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
}
|
||||
|
||||
await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('Failed to save app config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.config ? this.config[key] : this.defaultConfig[key];
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (!this.config) {
|
||||
this.config = { ...this.defaultConfig };
|
||||
}
|
||||
this.config[key] = value;
|
||||
}
|
||||
|
||||
async update(updates) {
|
||||
if (!this.config) {
|
||||
this.config = { ...this.defaultConfig };
|
||||
}
|
||||
|
||||
Object.assign(this.config, updates);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getDataDirectory() {
|
||||
return this.get('dataDirectory');
|
||||
}
|
||||
|
||||
async setDataDirectory(dataDir) {
|
||||
const resolvedPath = path.resolve(dataDir);
|
||||
|
||||
// Validate that the directory exists or can be created
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Try to create the directory
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (createError) {
|
||||
throw new Error(`Cannot create data directory: ${createError.message}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Cannot access data directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.set('dataDirectory', resolvedPath);
|
||||
await this.save();
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AppConfig();
|
288
utils/auth.js
Normal file
288
utils/auth.js
Normal file
@@ -0,0 +1,288 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3');
|
||||
const { promisify } = require('util');
|
||||
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.dbPath = path.join(process.cwd(), 'users.db');
|
||||
this.db = null;
|
||||
this.saltRounds = 12;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db = new sqlite3.Database(this.dbPath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create users table if it doesn't exist
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (created_by) REFERENCES users (id)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createUser(username, password, createdByUserId = null) {
|
||||
if (!username || !password) {
|
||||
throw new Error('Username and password are required');
|
||||
}
|
||||
|
||||
// Check if this is not the first user and no creator is specified
|
||||
const isFirstUser = await this.isFirstUser();
|
||||
if (!isFirstUser && !createdByUserId) {
|
||||
throw new Error('Only existing administrators can create new accounts');
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!/^[a-zA-Z0-9_-]{3,20}$/.test(username)) {
|
||||
throw new Error('Username must be 3-20 characters, letters, numbers, underscore, or hyphen only');
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, this.saltRounds);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO users (username, password_hash, created_by)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run([username, passwordHash, createdByUserId], function(err) {
|
||||
if (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
reject(new Error('Username already exists'));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
id: this.lastID,
|
||||
username: username,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
stmt.finalize();
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Failed to create user: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async authenticateUser(username, password) {
|
||||
if (!username || !password) {
|
||||
throw new Error('Username and password are required');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
'SELECT * FROM users WHERE username = ? AND is_active = 1',
|
||||
[username],
|
||||
async (err, user) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reject(new Error('Invalid username or password'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!passwordMatch) {
|
||||
reject(new Error('Invalid username or password'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last login
|
||||
this.db.run(
|
||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Return user info (without password hash)
|
||||
resolve({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login
|
||||
});
|
||||
} catch (bcryptError) {
|
||||
reject(bcryptError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getUserById(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1',
|
||||
[id],
|
||||
(err, user) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(user || null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByUsername(username) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE username = ? AND is_active = 1',
|
||||
[username],
|
||||
(err, user) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(user || null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllUsers() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 ORDER BY created_at DESC',
|
||||
[],
|
||||
(err, users) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(users || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE users SET is_active = 0 WHERE id = ?',
|
||||
[id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(this.changes > 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(id, currentPassword, newPassword) {
|
||||
if (!currentPassword || !newPassword) {
|
||||
throw new Error('Current password and new password are required');
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
throw new Error('New password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
'SELECT password_hash FROM users WHERE id = ? AND is_active = 1',
|
||||
[id],
|
||||
async (err, user) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reject(new Error('User not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
|
||||
if (!passwordMatch) {
|
||||
reject(new Error('Current password is incorrect'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, this.saltRounds);
|
||||
|
||||
this.db.run(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[newPasswordHash, id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(this.changes > 0);
|
||||
}
|
||||
);
|
||||
} catch (bcryptError) {
|
||||
reject(bcryptError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async isFirstUser() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
'SELECT COUNT(*) as count FROM users WHERE is_active = 1',
|
||||
[],
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(result.count === 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthManager;
|
442
utils/config-manager.js
Normal file
442
utils/config-manager.js
Normal file
@@ -0,0 +1,442 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const paths = require('./paths');
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.configPath = paths.configFile;
|
||||
this.configSections = this.getConfigSections();
|
||||
}
|
||||
|
||||
getConfigSections() {
|
||||
return {
|
||||
'Server': {
|
||||
description: 'Basic server settings',
|
||||
settings: {
|
||||
'server_name': {
|
||||
type: 'string',
|
||||
default: 'Luanti Server',
|
||||
description: 'Name of the server as displayed in the server list'
|
||||
},
|
||||
'server_description': {
|
||||
type: 'text',
|
||||
default: 'A Luanti server powered by the web interface',
|
||||
description: 'Server description shown to players'
|
||||
},
|
||||
'port': {
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
min: 1024,
|
||||
max: 65535,
|
||||
description: 'Port for the game server'
|
||||
},
|
||||
'max_users': {
|
||||
type: 'number',
|
||||
default: 15,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
description: 'Maximum number of players'
|
||||
},
|
||||
'motd': {
|
||||
type: 'text',
|
||||
default: 'Welcome to the server!',
|
||||
description: 'Message of the day shown to connecting players'
|
||||
},
|
||||
'server_announce': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Announce server to the public server list'
|
||||
},
|
||||
'serverlist_url': {
|
||||
type: 'string',
|
||||
default: 'servers.minetest.net',
|
||||
description: 'Server list URL for announcements'
|
||||
}
|
||||
}
|
||||
},
|
||||
'World': {
|
||||
description: 'World and gameplay settings',
|
||||
note: 'Many world settings can also be configured per-world in /worlds',
|
||||
settings: {
|
||||
'default_game': {
|
||||
type: 'string',
|
||||
default: 'minetest_game',
|
||||
description: 'Default game/subgame to use for new worlds'
|
||||
},
|
||||
'creative_mode': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable creative mode by default'
|
||||
},
|
||||
'enable_damage': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player damage and health'
|
||||
},
|
||||
'enable_pvp': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player vs player combat'
|
||||
},
|
||||
'disable_fire': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Disable fire spreading and burning'
|
||||
},
|
||||
'time_speed': {
|
||||
type: 'number',
|
||||
default: 72,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
description: 'Time speed (72 = 1 real day = 20 minutes game time)'
|
||||
}
|
||||
}
|
||||
},
|
||||
'Performance': {
|
||||
description: 'Server performance and limits',
|
||||
settings: {
|
||||
'dedicated_server_step': {
|
||||
type: 'number',
|
||||
default: 0.09,
|
||||
min: 0.01,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
description: 'Time step for dedicated server (seconds)'
|
||||
},
|
||||
'max_block_generate_distance': {
|
||||
type: 'number',
|
||||
default: 8,
|
||||
min: 1,
|
||||
max: 50,
|
||||
description: 'Maximum distance for generating new blocks'
|
||||
},
|
||||
'max_block_send_distance': {
|
||||
type: 'number',
|
||||
default: 12,
|
||||
min: 1,
|
||||
max: 50,
|
||||
description: 'Maximum distance for sending blocks to clients'
|
||||
},
|
||||
'active_block_range': {
|
||||
type: 'number',
|
||||
default: 4,
|
||||
min: 1,
|
||||
max: 20,
|
||||
description: 'Blocks within this distance are kept active'
|
||||
},
|
||||
'max_simultaneous_block_sends_per_client': {
|
||||
type: 'number',
|
||||
default: 40,
|
||||
min: 1,
|
||||
max: 200,
|
||||
description: 'Max blocks sent to each client per step'
|
||||
}
|
||||
}
|
||||
},
|
||||
'Security': {
|
||||
description: 'Security and authentication settings',
|
||||
settings: {
|
||||
'disallow_empty_password': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Require non-empty passwords for players'
|
||||
},
|
||||
'enable_rollback_recording': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Record player actions for rollback'
|
||||
},
|
||||
'kick_msg_crash': {
|
||||
type: 'string',
|
||||
default: 'This server has experienced an internal error. You will now be disconnected.',
|
||||
description: 'Message shown to players when server crashes'
|
||||
},
|
||||
'ask_reconnect_on_crash': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Ask players to reconnect after server crashes'
|
||||
}
|
||||
}
|
||||
},
|
||||
'Network': {
|
||||
description: 'Network and connection settings',
|
||||
settings: {
|
||||
'enable_ipv6': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable IPv6 support'
|
||||
},
|
||||
'ipv6_server': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Use IPv6 for server socket'
|
||||
},
|
||||
'max_packets_per_iteration': {
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 1,
|
||||
max: 10000,
|
||||
description: 'Maximum packets processed per network iteration'
|
||||
}
|
||||
}
|
||||
},
|
||||
'Advanced': {
|
||||
description: 'Advanced server settings',
|
||||
settings: {
|
||||
'enable_mod_channels': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable mod channels for mod communication'
|
||||
},
|
||||
'csm_restriction_flags': {
|
||||
type: 'number',
|
||||
default: 62,
|
||||
description: 'Client-side mod restriction flags (bitmask)'
|
||||
},
|
||||
'csm_restriction_noderange': {
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Limit client-side mod node range'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async readConfig() {
|
||||
try {
|
||||
const content = await fs.readFile(this.configPath, 'utf8');
|
||||
return this.parseConfig(content);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Config file doesn't exist, return empty config
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
parseConfig(content) {
|
||||
const config = {};
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key = value pairs
|
||||
const equalIndex = trimmed.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
const value = trimmed.substring(equalIndex + 1).trim();
|
||||
|
||||
config[key] = this.parseValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
parseValue(value) {
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Try to parse as number
|
||||
if (!isNaN(value) && !isNaN(parseFloat(value))) {
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
// Parse boolean
|
||||
if (value.toLowerCase() === 'true') return true;
|
||||
if (value.toLowerCase() === 'false') return false;
|
||||
|
||||
// Return as string
|
||||
return value;
|
||||
}
|
||||
|
||||
async writeConfig(config) {
|
||||
const lines = ['# Minetest configuration file', '# Generated by HostBlock', ''];
|
||||
|
||||
// Group settings by section
|
||||
const usedKeys = new Set();
|
||||
|
||||
for (const [sectionName, section] of Object.entries(this.configSections)) {
|
||||
let hasValues = false;
|
||||
const sectionLines = [];
|
||||
|
||||
sectionLines.push(`# ${section.description}`);
|
||||
if (section.note) {
|
||||
sectionLines.push(`# ${section.note}`);
|
||||
}
|
||||
|
||||
for (const [key, setting] of Object.entries(section.settings)) {
|
||||
if (config.hasOwnProperty(key)) {
|
||||
const value = config[key];
|
||||
const formattedValue = this.formatValue(value, setting.type);
|
||||
sectionLines.push(`${key} = ${formattedValue}`);
|
||||
usedKeys.add(key);
|
||||
hasValues = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValues) {
|
||||
lines.push(...sectionLines);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Add any unknown settings at the end
|
||||
const unknownSettings = Object.keys(config).filter(key => !usedKeys.has(key));
|
||||
if (unknownSettings.length > 0) {
|
||||
lines.push('# Other settings');
|
||||
for (const key of unknownSettings) {
|
||||
const value = config[key];
|
||||
lines.push(`${key} = ${this.formatValue(value)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const content = lines.join('\n');
|
||||
|
||||
// Create backup of existing config
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
const backupPath = `${this.configPath}.backup.${Date.now()}`;
|
||||
await fs.copyFile(this.configPath, backupPath);
|
||||
} catch (error) {
|
||||
// Original config doesn't exist, no backup needed
|
||||
}
|
||||
|
||||
// Write new config
|
||||
await fs.writeFile(this.configPath, content, 'utf8');
|
||||
|
||||
return { success: true, message: 'Configuration saved successfully' };
|
||||
}
|
||||
|
||||
formatValue(value, type = null) {
|
||||
if (type === 'string' || type === 'text') {
|
||||
// Quote strings that contain spaces or special characters
|
||||
if (typeof value === 'string' && (value.includes(' ') || value.includes('#'))) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async updateSetting(key, value) {
|
||||
const config = await this.readConfig();
|
||||
config[key] = value;
|
||||
return await this.writeConfig(config);
|
||||
}
|
||||
|
||||
async updateSettings(settings) {
|
||||
const config = await this.readConfig();
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
config[key] = value;
|
||||
}
|
||||
|
||||
return await this.writeConfig(config);
|
||||
}
|
||||
|
||||
async resetToDefaults(section = null) {
|
||||
const config = await this.readConfig();
|
||||
|
||||
if (section && this.configSections[section]) {
|
||||
// Reset specific section
|
||||
for (const [key, setting] of Object.entries(this.configSections[section].settings)) {
|
||||
if (setting.default !== undefined) {
|
||||
config[key] = setting.default;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset all sections
|
||||
for (const section of Object.values(this.configSections)) {
|
||||
for (const [key, setting] of Object.entries(section.settings)) {
|
||||
if (setting.default !== undefined) {
|
||||
config[key] = setting.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await this.writeConfig(config);
|
||||
}
|
||||
|
||||
validateSetting(key, value) {
|
||||
// Find the setting definition
|
||||
let settingDef = null;
|
||||
|
||||
for (const section of Object.values(this.configSections)) {
|
||||
if (section.settings[key]) {
|
||||
settingDef = section.settings[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!settingDef) {
|
||||
// Unknown setting, allow any value
|
||||
return { valid: true, value };
|
||||
}
|
||||
|
||||
// Type validation
|
||||
switch (settingDef.type) {
|
||||
case 'boolean':
|
||||
if (typeof value === 'string') {
|
||||
if (value.toLowerCase() === 'true') return { valid: true, value: true };
|
||||
if (value.toLowerCase() === 'false') return { valid: true, value: false };
|
||||
return { valid: false, error: 'Must be true or false' };
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'Must be a boolean value' };
|
||||
|
||||
case 'number':
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
return { valid: false, error: 'Must be a number' };
|
||||
}
|
||||
if (settingDef.min !== undefined && num < settingDef.min) {
|
||||
return { valid: false, error: `Must be at least ${settingDef.min}` };
|
||||
}
|
||||
if (settingDef.max !== undefined && num > settingDef.max) {
|
||||
return { valid: false, error: `Must be at most ${settingDef.max}` };
|
||||
}
|
||||
return { valid: true, value: num };
|
||||
|
||||
case 'string':
|
||||
case 'text':
|
||||
return { valid: true, value: String(value) };
|
||||
|
||||
default:
|
||||
return { valid: true, value };
|
||||
}
|
||||
}
|
||||
|
||||
getSettingInfo(key) {
|
||||
for (const [sectionName, section] of Object.entries(this.configSections)) {
|
||||
if (section.settings[key]) {
|
||||
return {
|
||||
section: sectionName,
|
||||
...section.settings[key]
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAllSettings() {
|
||||
return this.configSections;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigManager;
|
125
utils/config-parser.js
Normal file
125
utils/config-parser.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class ConfigParser {
|
||||
static async parseConfig(filePath) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const config = {};
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const equalIndex = trimmed.indexOf('=');
|
||||
if (equalIndex === -1) continue;
|
||||
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
const value = trimmed.substring(equalIndex + 1).trim();
|
||||
|
||||
config[key] = value;
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async writeConfig(filePath, config) {
|
||||
const lines = [];
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
lines.push(`${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf8');
|
||||
}
|
||||
|
||||
static async parseModConfig(filePath) {
|
||||
const config = await this.parseConfig(filePath);
|
||||
|
||||
if (config.depends) {
|
||||
config.depends = config.depends.split(',').map(dep => dep.trim()).filter(Boolean);
|
||||
} else {
|
||||
config.depends = [];
|
||||
}
|
||||
|
||||
if (config.optional_depends) {
|
||||
config.optional_depends = config.optional_depends.split(',').map(dep => dep.trim()).filter(Boolean);
|
||||
} else {
|
||||
config.optional_depends = [];
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
static async writeModConfig(filePath, config) {
|
||||
const configCopy = { ...config };
|
||||
|
||||
if (Array.isArray(configCopy.depends)) {
|
||||
configCopy.depends = configCopy.depends.join(', ');
|
||||
}
|
||||
|
||||
if (Array.isArray(configCopy.optional_depends)) {
|
||||
configCopy.optional_depends = configCopy.optional_depends.join(', ');
|
||||
}
|
||||
|
||||
await this.writeConfig(filePath, configCopy);
|
||||
}
|
||||
|
||||
static async parseWorldConfig(filePath) {
|
||||
const config = await this.parseConfig(filePath);
|
||||
|
||||
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
|
||||
for (const field of booleanFields) {
|
||||
if (config[field] !== undefined) {
|
||||
config[field] = config[field] === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
static async writeWorldConfig(filePath, config) {
|
||||
const configCopy = { ...config };
|
||||
|
||||
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
|
||||
for (const field of booleanFields) {
|
||||
if (typeof configCopy[field] === 'boolean') {
|
||||
configCopy[field] = configCopy[field].toString();
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeConfig(filePath, configCopy);
|
||||
}
|
||||
|
||||
static async parseGameConfig(filePath) {
|
||||
const config = await this.parseConfig(filePath);
|
||||
|
||||
// Parse common game config fields
|
||||
if (config.name) {
|
||||
config.name = config.name.trim();
|
||||
}
|
||||
if (config.title) {
|
||||
config.title = config.title.trim();
|
||||
}
|
||||
if (config.description) {
|
||||
config.description = config.description.trim();
|
||||
}
|
||||
if (config.author) {
|
||||
config.author = config.author.trim();
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigParser;
|
202
utils/contentdb-url.js
Normal file
202
utils/contentdb-url.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* ContentDB URL Parser and Validator
|
||||
* Handles parsing and validation of ContentDB package URLs
|
||||
*/
|
||||
|
||||
class ContentDBUrlParser {
|
||||
/**
|
||||
* Parse a ContentDB URL to extract author and package name
|
||||
* @param {string} url - The URL to parse
|
||||
* @returns {Object} - {author, name, isValid, originalUrl}
|
||||
*/
|
||||
static parseUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'URL is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up the URL
|
||||
let cleanUrl = url.trim();
|
||||
|
||||
// Remove protocol
|
||||
cleanUrl = cleanUrl.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove trailing slash
|
||||
cleanUrl = cleanUrl.replace(/\/$/, '');
|
||||
|
||||
// Define patterns to match
|
||||
const patterns = [
|
||||
// Full ContentDB URL: content.luanti.org/packages/author/name
|
||||
/^content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
|
||||
|
||||
// Alternative domain patterns (if any)
|
||||
/^(?:www\.)?content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
|
||||
|
||||
// Direct author/name format
|
||||
/^([^\/\s]+)\/([^\/\s]+)$/
|
||||
];
|
||||
|
||||
// Try each pattern
|
||||
for (const pattern of patterns) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
const author = match[1];
|
||||
const name = match[2];
|
||||
|
||||
// Validate author and name format
|
||||
if (this.isValidIdentifier(author) && this.isValidIdentifier(name)) {
|
||||
return {
|
||||
author: author,
|
||||
name: name,
|
||||
isValid: true,
|
||||
originalUrl: url,
|
||||
cleanUrl: cleanUrl,
|
||||
fullUrl: `https://content.luanti.org/packages/${author}/${name}/`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'Invalid author or package name format'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'URL format not recognized'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an identifier (author or package name)
|
||||
* @param {string} identifier - The identifier to validate
|
||||
* @returns {boolean} - Whether the identifier is valid
|
||||
*/
|
||||
static isValidIdentifier(identifier) {
|
||||
if (!identifier || typeof identifier !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ContentDB identifiers should be alphanumeric with underscores and hyphens
|
||||
// Length should be reasonable (3-50 characters)
|
||||
return /^[a-zA-Z0-9_-]{3,50}$/.test(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate various URL formats for a package
|
||||
* @param {string} author - Package author
|
||||
* @param {string} name - Package name
|
||||
* @returns {Object} - Object containing different URL formats
|
||||
*/
|
||||
static generateUrls(author, name) {
|
||||
if (!this.isValidIdentifier(author) || !this.isValidIdentifier(name)) {
|
||||
throw new Error('Invalid author or package name');
|
||||
}
|
||||
|
||||
return {
|
||||
web: `https://content.luanti.org/packages/${author}/${name}/`,
|
||||
api: `https://content.luanti.org/api/packages/${author}/${name}/`,
|
||||
releases: `https://content.luanti.org/api/packages/${author}/${name}/releases/`,
|
||||
direct: `${author}/${name}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple URL formats and suggest corrections
|
||||
* @param {string} url - The URL to validate
|
||||
* @returns {Object} - Validation result with suggestions
|
||||
*/
|
||||
static validateWithSuggestions(url) {
|
||||
const result = this.parseUrl(url);
|
||||
|
||||
if (result.isValid) {
|
||||
return {
|
||||
...result,
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
// Generate suggestions for common mistakes
|
||||
const suggestions = [];
|
||||
|
||||
if (url.includes('minetest.') || url.includes('minetest/')) {
|
||||
suggestions.push('Did you mean content.luanti.org instead of minetest?');
|
||||
}
|
||||
|
||||
if (url.includes('://content.luanti.org') && !url.includes('/packages/')) {
|
||||
suggestions.push('Make sure the URL includes /packages/author/name/');
|
||||
}
|
||||
|
||||
if (url.includes(' ')) {
|
||||
suggestions.push('Remove spaces from the URL');
|
||||
}
|
||||
|
||||
// Check if it looks like a partial URL
|
||||
if (url.includes('/') && !url.includes('content.luanti.org')) {
|
||||
suggestions.push('Try the full URL: https://content.luanti.org/packages/author/name/');
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
suggestions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract package information from various URL formats
|
||||
* @param {string} url - The URL to extract from
|
||||
* @returns {Promise<Object>} - Package information if available
|
||||
*/
|
||||
static async extractPackageInfo(url) {
|
||||
const parsed = this.parseUrl(url);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
throw new Error(parsed.error || 'Invalid URL format');
|
||||
}
|
||||
|
||||
return {
|
||||
author: parsed.author,
|
||||
name: parsed.name,
|
||||
identifier: `${parsed.author}/${parsed.name}`,
|
||||
urls: this.generateUrls(parsed.author, parsed.name)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a ContentDB package URL
|
||||
* @param {string} url - The URL to check
|
||||
* @returns {boolean} - Whether it's a ContentDB package URL
|
||||
*/
|
||||
static isContentDBUrl(url) {
|
||||
return this.parseUrl(url).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a URL to standard format
|
||||
* @param {string} url - The URL to normalize
|
||||
* @returns {string} - Normalized URL
|
||||
*/
|
||||
static normalizeUrl(url) {
|
||||
const parsed = this.parseUrl(url);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
throw new Error(parsed.error || 'Invalid URL format');
|
||||
}
|
||||
|
||||
return parsed.fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ContentDBUrlParser;
|
332
utils/contentdb.js
Normal file
332
utils/contentdb.js
Normal file
@@ -0,0 +1,332 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
const yauzl = require('yauzl');
|
||||
const { promisify } = require('util');
|
||||
|
||||
class ContentDBClient {
|
||||
constructor() {
|
||||
this.baseURL = 'https://content.luanti.org/api';
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'User-Agent': 'LuHost/1.0',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
validateStatus: (status) => {
|
||||
// Only treat 200-299 as success, but don't throw on 404
|
||||
return (status >= 200 && status < 300) || status === 404;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search packages (mods, games, texture packs)
|
||||
async searchPackages(query = '', type = '', sort = 'score', order = 'desc', limit = 20, offset = 0) {
|
||||
try {
|
||||
const params = {
|
||||
q: query,
|
||||
type: type, // mod, game, txp (texture pack)
|
||||
sort: sort, // score, name, created_at, approved_at, downloads
|
||||
order: order, // asc, desc
|
||||
limit: Math.min(limit, 50), // API limit
|
||||
offset: offset
|
||||
};
|
||||
|
||||
const response = await this.client.get('/packages/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to search ContentDB: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get package details
|
||||
async getPackage(author, name) {
|
||||
try {
|
||||
const response = await this.client.get(`/packages/${author}/${name}/`);
|
||||
|
||||
// Ensure we got JSON back
|
||||
if (typeof response.data !== 'object') {
|
||||
throw new Error('Invalid response format from ContentDB');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new Error('Package not found');
|
||||
}
|
||||
|
||||
// Handle cases where the response isn't JSON
|
||||
if (error.message.includes('JSON') || error.message.includes('Unexpected token')) {
|
||||
throw new Error('ContentDB returned invalid data format');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to get package details: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get package releases
|
||||
async getPackageReleases(author, name) {
|
||||
try {
|
||||
const response = await this.client.get(`/packages/${author}/${name}/releases/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get package releases: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Download package
|
||||
async downloadPackage(author, name, targetPath, version = null) {
|
||||
try {
|
||||
// Get package info first
|
||||
const packageInfo = await this.getPackage(author, name);
|
||||
|
||||
// Get releases to find download URL
|
||||
const releases = await this.getPackageReleases(author, name);
|
||||
|
||||
if (!releases || releases.length === 0) {
|
||||
throw new Error('No releases found for this package');
|
||||
}
|
||||
|
||||
// Find the specified version or use the latest
|
||||
let release;
|
||||
if (version) {
|
||||
release = releases.find(r => r.id === version || r.title === version);
|
||||
if (!release) {
|
||||
throw new Error(`Version ${version} not found`);
|
||||
}
|
||||
} else {
|
||||
// Use the first release (should be latest)
|
||||
release = releases[0];
|
||||
}
|
||||
|
||||
if (!release.url) {
|
||||
throw new Error('No download URL found for this release');
|
||||
}
|
||||
|
||||
// Construct full download URL if needed
|
||||
let downloadUrl = release.url;
|
||||
if (downloadUrl.startsWith('/')) {
|
||||
downloadUrl = 'https://content.luanti.org' + downloadUrl;
|
||||
}
|
||||
|
||||
// Download the package
|
||||
const downloadResponse = await axios.get(downloadUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 120000, // 2 minutes for download
|
||||
headers: {
|
||||
'User-Agent': 'LuHost/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
// Create target directory
|
||||
await fs.mkdir(targetPath, { recursive: true });
|
||||
|
||||
// If it's a zip file, extract it
|
||||
if (release.url.endsWith('.zip')) {
|
||||
const tempZipPath = path.join(targetPath, 'temp.zip');
|
||||
|
||||
// Save zip file temporarily
|
||||
const writer = require('fs').createWriteStream(tempZipPath);
|
||||
downloadResponse.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve);
|
||||
writer.on('error', reject);
|
||||
});
|
||||
|
||||
// Extract zip file
|
||||
await this.extractZipFile(tempZipPath, targetPath);
|
||||
|
||||
// Remove temp zip file
|
||||
await fs.unlink(tempZipPath);
|
||||
} else {
|
||||
// For non-zip files, save directly
|
||||
const fileName = path.basename(release.url) || 'download';
|
||||
const filePath = path.join(targetPath, fileName);
|
||||
const writer = require('fs').createWriteStream(filePath);
|
||||
downloadResponse.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve);
|
||||
writer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
package: packageInfo,
|
||||
release: release,
|
||||
downloadPath: targetPath
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to download package: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract zip file
|
||||
async extractZipFile(zipPath, targetPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on('entry', async (entry) => {
|
||||
const entryPath = path.join(targetPath, entry.fileName);
|
||||
|
||||
// Ensure the entry path is within target directory (security)
|
||||
const normalizedPath = path.normalize(entryPath);
|
||||
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\/$/.test(entry.fileName)) {
|
||||
// Directory entry
|
||||
try {
|
||||
await fs.mkdir(normalizedPath, { recursive: true });
|
||||
zipfile.readEntry();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
// File entry
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(normalizedPath);
|
||||
fs.mkdir(parentDir, { recursive: true })
|
||||
.then(() => {
|
||||
const writeStream = require('fs').createWriteStream(normalizedPath);
|
||||
readStream.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
writeStream.on('error', reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
zipfile.on('error', reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get popular packages
|
||||
async getPopularPackages(type = '', limit = 10) {
|
||||
return this.searchPackages('', type, 'downloads', 'desc', limit, 0);
|
||||
}
|
||||
|
||||
// Get recently updated packages
|
||||
async getRecentPackages(type = '', limit = 10) {
|
||||
return this.searchPackages('', type, 'approved_at', 'desc', limit, 0);
|
||||
}
|
||||
|
||||
// Check for updates for installed packages
|
||||
async checkForUpdates(installedPackages) {
|
||||
const updates = [];
|
||||
|
||||
for (const pkg of installedPackages) {
|
||||
try {
|
||||
// Try to find the package on ContentDB
|
||||
// This requires matching local package names to ContentDB packages
|
||||
// which might not always be straightforward
|
||||
|
||||
// For now, we'll implement a basic search-based approach
|
||||
const searchResults = await this.searchPackages(pkg.name, '', 'score', 'desc', 5);
|
||||
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
// Try to find exact match
|
||||
const match = searchResults.find(result =>
|
||||
result.name.toLowerCase() === pkg.name.toLowerCase() ||
|
||||
result.title.toLowerCase() === pkg.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const releases = await this.getPackageReleases(match.author, match.name);
|
||||
if (releases && releases.length > 0) {
|
||||
updates.push({
|
||||
local: pkg,
|
||||
remote: match,
|
||||
latestRelease: releases[0],
|
||||
hasUpdate: true // We could implement version comparison here
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip packages that can't be found or checked
|
||||
console.warn(`Could not check updates for ${pkg.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
// Get package dependencies
|
||||
async getPackageDependencies(author, name) {
|
||||
try {
|
||||
const packageInfo = await this.getPackage(author, name);
|
||||
return {
|
||||
hard_dependencies: packageInfo.hard_dependencies || [],
|
||||
optional_dependencies: packageInfo.optional_dependencies || []
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get dependencies: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Install package with dependencies
|
||||
async installPackageWithDeps(author, name, targetBasePath, resolveDeps = true) {
|
||||
const installResults = {
|
||||
main: null,
|
||||
dependencies: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Install main package
|
||||
const mainPackagePath = path.join(targetBasePath, name);
|
||||
const mainResult = await this.downloadPackage(author, name, mainPackagePath);
|
||||
installResults.main = mainResult;
|
||||
|
||||
// Install dependencies if requested
|
||||
if (resolveDeps) {
|
||||
const deps = await this.getPackageDependencies(author, name);
|
||||
|
||||
for (const dep of deps.hard_dependencies) {
|
||||
try {
|
||||
const depPath = path.join(targetBasePath, dep.name);
|
||||
const depResult = await this.downloadPackage(dep.author, dep.name, depPath);
|
||||
installResults.dependencies.push(depResult);
|
||||
} catch (error) {
|
||||
installResults.errors.push(`Failed to install dependency ${dep.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return installResults;
|
||||
} catch (error) {
|
||||
installResults.errors.push(error.message);
|
||||
return installResults;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ContentDBClient;
|
256
utils/package-registry.js
Normal file
256
utils/package-registry.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
class PackageRegistry {
|
||||
constructor(dbPath = 'data/packages.db') {
|
||||
this.dbPath = dbPath;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Ensure data directory exists
|
||||
const dir = path.dirname(this.dbPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db = new sqlite3.Database(this.dbPath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.createTables().then(resolve).catch(reject);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createTables() {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS installed_packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT,
|
||||
release_id TEXT,
|
||||
install_location TEXT NOT NULL, -- 'global' or 'world:worldname'
|
||||
install_path TEXT NOT NULL,
|
||||
installed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
contentdb_url TEXT,
|
||||
package_type TEXT, -- 'mod', 'game', 'txp'
|
||||
title TEXT,
|
||||
short_description TEXT,
|
||||
dependencies TEXT, -- JSON string of dependencies
|
||||
UNIQUE(author, name, install_location)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_package_location ON installed_packages(install_location);
|
||||
CREATE INDEX IF NOT EXISTS idx_package_name ON installed_packages(author, name);
|
||||
`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.exec(sql, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async recordInstallation(packageInfo) {
|
||||
const {
|
||||
author,
|
||||
name,
|
||||
version,
|
||||
releaseId,
|
||||
installLocation,
|
||||
installPath,
|
||||
contentdbUrl,
|
||||
packageType,
|
||||
title,
|
||||
shortDescription,
|
||||
dependencies = []
|
||||
} = packageInfo;
|
||||
|
||||
const sql = `
|
||||
INSERT OR REPLACE INTO installed_packages
|
||||
(author, name, version, release_id, install_location, install_path,
|
||||
contentdb_url, package_type, title, short_description, dependencies, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`;
|
||||
|
||||
const dependenciesJson = JSON.stringify(dependencies);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, [
|
||||
author, name, version, releaseId, installLocation, installPath,
|
||||
contentdbUrl, packageType, title, shortDescription, dependenciesJson
|
||||
], function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ id: this.lastID, changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledPackages(location = null) {
|
||||
let sql = 'SELECT * FROM installed_packages';
|
||||
const params = [];
|
||||
|
||||
if (location) {
|
||||
if (location === 'global') {
|
||||
sql += ' WHERE install_location = ?';
|
||||
params.push('global');
|
||||
} else if (location.startsWith('world:')) {
|
||||
sql += ' WHERE install_location = ?';
|
||||
params.push(location);
|
||||
}
|
||||
}
|
||||
|
||||
sql += ' ORDER BY installed_at DESC';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
// Parse dependencies JSON for each row
|
||||
const packages = rows.map(row => ({
|
||||
...row,
|
||||
dependencies: row.dependencies ? JSON.parse(row.dependencies) : []
|
||||
}));
|
||||
resolve(packages);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledPackage(author, name, location) {
|
||||
const sql = `
|
||||
SELECT * FROM installed_packages
|
||||
WHERE author = ? AND name = ? AND install_location = ?
|
||||
`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(sql, [author, name, location], (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (row) {
|
||||
row.dependencies = row.dependencies ? JSON.parse(row.dependencies) : [];
|
||||
resolve(row);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async isPackageInstalled(author, name, location) {
|
||||
const packageInfo = await this.getInstalledPackage(author, name, location);
|
||||
return packageInfo !== null;
|
||||
}
|
||||
|
||||
async removePackage(author, name, location) {
|
||||
const sql = `
|
||||
DELETE FROM installed_packages
|
||||
WHERE author = ? AND name = ? AND install_location = ?
|
||||
`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, [author, name, location], function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getPackagesByWorld(worldName) {
|
||||
return this.getInstalledPackages(`world:${worldName}`);
|
||||
}
|
||||
|
||||
async getGlobalPackages() {
|
||||
return this.getInstalledPackages('global');
|
||||
}
|
||||
|
||||
async getAllInstallations() {
|
||||
return this.getInstalledPackages();
|
||||
}
|
||||
|
||||
async updatePackageInfo(author, name, location, updates) {
|
||||
const setClause = [];
|
||||
const params = [];
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key === 'dependencies' && Array.isArray(value)) {
|
||||
setClause.push(`${key} = ?`);
|
||||
params.push(JSON.stringify(value));
|
||||
} else {
|
||||
setClause.push(`${key} = ?`);
|
||||
params.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
setClause.push('updated_at = CURRENT_TIMESTAMP');
|
||||
|
||||
const sql = `
|
||||
UPDATE installed_packages
|
||||
SET ${setClause.join(', ')}
|
||||
WHERE author = ? AND name = ? AND install_location = ?
|
||||
`;
|
||||
|
||||
params.push(author, name, location);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getStatistics() {
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total_packages,
|
||||
COUNT(CASE WHEN install_location = 'global' THEN 1 END) as global_packages,
|
||||
COUNT(CASE WHEN install_location LIKE 'world:%' THEN 1 END) as world_packages,
|
||||
COUNT(DISTINCT CASE WHEN install_location LIKE 'world:%' THEN install_location END) as worlds_with_packages
|
||||
FROM installed_packages
|
||||
`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(sql, [], (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.db) {
|
||||
return new Promise((resolve) => {
|
||||
this.db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing database:', err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PackageRegistry;
|
194
utils/paths.js
Normal file
194
utils/paths.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const appConfig = require('./app-config');
|
||||
|
||||
class LuantiPaths {
|
||||
constructor() {
|
||||
// Initialize with default, will be updated when app config loads
|
||||
this.setDataDirectory(this.getDefaultDataDirectory());
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Load app config and update data directory
|
||||
await appConfig.load();
|
||||
const configuredDataDir = appConfig.getDataDirectory();
|
||||
this.setDataDirectory(configuredDataDir);
|
||||
}
|
||||
|
||||
getDefaultDataDirectory() {
|
||||
// Check for common Luanti data directories
|
||||
const homeDir = os.homedir();
|
||||
const possibleDirs = [
|
||||
path.join(homeDir, '.luanti'),
|
||||
path.join(homeDir, '.minetest')
|
||||
];
|
||||
|
||||
// Use the first one that exists, or default to .minetest
|
||||
for (const dir of possibleDirs) {
|
||||
if (fs.existsSync(dir)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(homeDir, '.minetest');
|
||||
}
|
||||
|
||||
setDataDirectory(dataDir) {
|
||||
this.minetestDir = path.resolve(dataDir);
|
||||
this.worldsDir = path.join(this.minetestDir, 'worlds');
|
||||
this.modsDir = path.join(this.minetestDir, 'mods');
|
||||
this.gamesDir = path.join(this.minetestDir, 'games');
|
||||
this.texturesDir = path.join(this.minetestDir, 'textures');
|
||||
this.configFile = path.join(this.minetestDir, 'minetest.conf');
|
||||
this.debugFile = path.join(this.minetestDir, 'debug.txt');
|
||||
}
|
||||
|
||||
getDataDirectory() {
|
||||
return this.minetestDir;
|
||||
}
|
||||
|
||||
getWorldPath(worldName) {
|
||||
return path.join(this.worldsDir, worldName);
|
||||
}
|
||||
|
||||
getWorldConfigPath(worldName) {
|
||||
return path.join(this.getWorldPath(worldName), 'world.mt');
|
||||
}
|
||||
|
||||
getWorldModsPath(worldName) {
|
||||
return path.join(this.getWorldPath(worldName), 'worldmods');
|
||||
}
|
||||
|
||||
getModPath(modName) {
|
||||
return path.join(this.modsDir, modName);
|
||||
}
|
||||
|
||||
getModConfigPath(modName) {
|
||||
return path.join(this.getModPath(modName), 'mod.conf');
|
||||
}
|
||||
|
||||
getGamePath(gameName) {
|
||||
return path.join(this.gamesDir, gameName);
|
||||
}
|
||||
|
||||
getGameConfigPath(gameName) {
|
||||
return path.join(this.getGamePath(gameName), 'game.conf');
|
||||
}
|
||||
|
||||
ensureDirectories() {
|
||||
const dirs = [this.minetestDir, this.worldsDir, this.modsDir, this.gamesDir, this.texturesDir];
|
||||
dirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isValidWorldName(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length >= 3 && name.length <= 50;
|
||||
}
|
||||
|
||||
isValidModName(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 50;
|
||||
}
|
||||
|
||||
isPathSafe(targetPath) {
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
return resolvedPath.startsWith(path.resolve(this.minetestDir));
|
||||
}
|
||||
|
||||
mapToActualGameId(directoryName) {
|
||||
// Map directory names to the actual game IDs that Luanti recognizes
|
||||
// For most cases, the directory name IS the game ID
|
||||
const gameIdMap = {
|
||||
// Only add mappings here if you're certain they're needed
|
||||
// 'minetest_game': 'minetest', // This mapping was incorrect
|
||||
};
|
||||
|
||||
return gameIdMap[directoryName] || directoryName;
|
||||
}
|
||||
|
||||
async getInstalledGames() {
|
||||
const games = [];
|
||||
const possibleGameDirs = [
|
||||
this.gamesDir, // User games directory
|
||||
'/usr/share/luanti/games', // System games directory
|
||||
'/usr/share/minetest/games', // Legacy system games directory
|
||||
path.join(process.env.HOME || '/root', '.minetest/games'), // Explicit user path
|
||||
path.join(process.env.HOME || '/root', '.luanti/games') // New user path
|
||||
];
|
||||
|
||||
for (const gameDir of possibleGameDirs) {
|
||||
try {
|
||||
const exists = fs.existsSync(gameDir);
|
||||
if (!exists) continue;
|
||||
|
||||
const gameDirs = fs.readdirSync(gameDir);
|
||||
for (const gameName of gameDirs) {
|
||||
const possibleConfigPaths = [
|
||||
path.join(gameDir, gameName, 'game.conf'),
|
||||
path.join(gameDir, gameName, gameName, 'game.conf') // Handle nested structure
|
||||
];
|
||||
|
||||
for (const gameConfigPath of possibleConfigPaths) {
|
||||
try {
|
||||
if (fs.existsSync(gameConfigPath)) {
|
||||
const ConfigParser = require('./config-parser');
|
||||
const gameConfig = await ConfigParser.parseGameConfig(gameConfigPath);
|
||||
|
||||
// Map directory names to actual game IDs that Luanti recognizes
|
||||
const actualGameId = this.mapToActualGameId(gameName);
|
||||
|
||||
// Check if we already have this game (avoid duplicates by game ID, title, and resolved path)
|
||||
const resolvedPath = fs.realpathSync(path.dirname(gameConfigPath));
|
||||
const existingGame = games.find(g =>
|
||||
g.name === actualGameId ||
|
||||
(g.title === (gameConfig.title || gameConfig.name || gameName) && g.resolvedPath === resolvedPath)
|
||||
);
|
||||
if (!existingGame) {
|
||||
games.push({
|
||||
name: actualGameId, // Use the ID that Luanti recognizes
|
||||
directoryName: gameName, // Keep original for path resolution
|
||||
title: gameConfig.title || gameConfig.name || gameName,
|
||||
description: gameConfig.description || '',
|
||||
author: gameConfig.author || '',
|
||||
path: path.dirname(gameConfigPath),
|
||||
resolvedPath: resolvedPath,
|
||||
isSystemGame: !gameDir.includes(this.minetestDir)
|
||||
});
|
||||
}
|
||||
break; // Found valid config, stop checking other paths
|
||||
}
|
||||
} catch (gameError) {
|
||||
// Skip invalid games
|
||||
console.warn(`Invalid game at ${gameConfigPath}:`, gameError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
// Skip directories that can't be read
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort games: system games first, then minetest_game first, then alphabetically
|
||||
games.sort((a, b) => {
|
||||
if (a.isSystemGame !== b.isSystemGame) {
|
||||
return a.isSystemGame ? -1 : 1;
|
||||
}
|
||||
|
||||
// Put minetest_game first as it's the default
|
||||
if (a.name === 'minetest_game') return -1;
|
||||
if (b.name === 'minetest_game') return 1;
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LuantiPaths();
|
206
utils/security-logger.js
Normal file
206
utils/security-logger.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class SecurityLogger {
|
||||
constructor() {
|
||||
this.logFile = path.join(process.cwd(), 'security.log');
|
||||
this.maxLogSize = 10 * 1024 * 1024; // 10MB
|
||||
this.maxLogFiles = 5;
|
||||
}
|
||||
|
||||
async log(level, event, details = {}, req = null) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Extract safe request information
|
||||
const requestInfo = req ? {
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.get('User-Agent'),
|
||||
method: req.method,
|
||||
url: req.originalUrl || req.url,
|
||||
userId: req.session?.user?.id,
|
||||
username: req.session?.user?.username
|
||||
} : {};
|
||||
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
event,
|
||||
details,
|
||||
request: requestInfo,
|
||||
pid: process.pid
|
||||
};
|
||||
|
||||
const logLine = JSON.stringify(logEntry) + '\n';
|
||||
|
||||
try {
|
||||
// Check if log rotation is needed
|
||||
await this.rotateLogIfNeeded();
|
||||
|
||||
// Append to log file
|
||||
await fs.appendFile(this.logFile, logLine);
|
||||
|
||||
// Also log to console for development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[SECURITY] ${level.toUpperCase()}: ${event}`, details);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to write security log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async rotateLogIfNeeded() {
|
||||
try {
|
||||
const stats = await fs.stat(this.logFile);
|
||||
|
||||
if (stats.size > this.maxLogSize) {
|
||||
// Rotate logs
|
||||
for (let i = this.maxLogFiles - 1; i > 0; i--) {
|
||||
const oldFile = `${this.logFile}.${i}`;
|
||||
const newFile = `${this.logFile}.${i + 1}`;
|
||||
|
||||
try {
|
||||
await fs.rename(oldFile, newFile);
|
||||
} catch (error) {
|
||||
// File might not exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Move current log to .1
|
||||
await fs.rename(this.logFile, `${this.logFile}.1`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log file might not exist yet, that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// Security event logging methods
|
||||
async logAuthSuccess(req, username) {
|
||||
await this.log('info', 'AUTH_SUCCESS', {
|
||||
username,
|
||||
sessionId: req.sessionID
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logAuthFailure(req, username, reason) {
|
||||
await this.log('warn', 'AUTH_FAILURE', {
|
||||
username,
|
||||
reason,
|
||||
sessionId: req.sessionID
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logCommandExecution(req, command, result) {
|
||||
await this.log('info', 'COMMAND_EXECUTION', {
|
||||
command,
|
||||
result: result ? 'success' : 'failed'
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logConfigChange(req, section, changes) {
|
||||
await this.log('info', 'CONFIG_CHANGE', {
|
||||
section,
|
||||
changes: Object.keys(changes)
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logSecurityViolation(req, violationType, details) {
|
||||
await this.log('error', 'SECURITY_VIOLATION', {
|
||||
violationType,
|
||||
details
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logServerStart(req, worldName, options = {}) {
|
||||
await this.log('info', 'SERVER_START', {
|
||||
worldName,
|
||||
options
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logServerStop(req, forced = false) {
|
||||
await this.log('info', 'SERVER_STOP', {
|
||||
forced
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logFileAccess(req, filePath, operation) {
|
||||
await this.log('info', 'FILE_ACCESS', {
|
||||
filePath,
|
||||
operation
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logSuspiciousActivity(req, activityType, details) {
|
||||
await this.log('warn', 'SUSPICIOUS_ACTIVITY', {
|
||||
activityType,
|
||||
details
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logRateLimitExceeded(req) {
|
||||
await this.log('warn', 'RATE_LIMIT_EXCEEDED', {
|
||||
limit: 'request_rate'
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logCSRFViolation(req) {
|
||||
await this.log('error', 'CSRF_VIOLATION', {
|
||||
referer: req.get('Referer'),
|
||||
origin: req.get('Origin')
|
||||
}, req);
|
||||
}
|
||||
|
||||
async logInputValidationFailure(req, field, value, reason) {
|
||||
await this.log('warn', 'INPUT_VALIDATION_FAILURE', {
|
||||
field,
|
||||
valueLength: value ? value.length : 0,
|
||||
reason
|
||||
}, req);
|
||||
}
|
||||
|
||||
// Read security logs (for admin interface)
|
||||
async getRecentLogs(limit = 100) {
|
||||
try {
|
||||
const content = await fs.readFile(this.logFile, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(line => line);
|
||||
|
||||
return lines.slice(-limit).map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return { error: 'Failed to parse log line', line };
|
||||
}
|
||||
}).reverse(); // Most recent first
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get security metrics
|
||||
async getSecurityMetrics(hours = 24) {
|
||||
const logs = await this.getRecentLogs(10000); // Large sample
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
const recentLogs = logs.filter(log =>
|
||||
log.timestamp && new Date(log.timestamp) > since
|
||||
);
|
||||
|
||||
const metrics = {
|
||||
totalEvents: recentLogs.length,
|
||||
authFailures: recentLogs.filter(log => log.event === 'AUTH_FAILURE').length,
|
||||
securityViolations: recentLogs.filter(log => log.event === 'SECURITY_VIOLATION').length,
|
||||
suspiciousActivity: recentLogs.filter(log => log.event === 'SUSPICIOUS_ACTIVITY').length,
|
||||
rateLimitExceeded: recentLogs.filter(log => log.event === 'RATE_LIMIT_EXCEEDED').length,
|
||||
csrfViolations: recentLogs.filter(log => log.event === 'CSRF_VIOLATION').length,
|
||||
commandExecutions: recentLogs.filter(log => log.event === 'COMMAND_EXECUTION').length,
|
||||
configChanges: recentLogs.filter(log => log.event === 'CONFIG_CHANGE').length
|
||||
};
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const securityLogger = new SecurityLogger();
|
||||
|
||||
module.exports = securityLogger;
|
768
utils/server-manager.js
Normal file
768
utils/server-manager.js
Normal file
@@ -0,0 +1,768 @@
|
||||
const { spawn, exec } = require('child_process');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const paths = require('./paths');
|
||||
|
||||
class ServerManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.serverProcess = null;
|
||||
this.isRunning = false;
|
||||
this.isReady = false; // Track if server is actually ready to accept connections
|
||||
this.startTime = null;
|
||||
this.logBuffer = [];
|
||||
this.maxLogLines = 1000;
|
||||
this.serverStats = {
|
||||
players: 0,
|
||||
uptime: 0,
|
||||
memoryUsage: 0,
|
||||
cpuUsage: 0
|
||||
};
|
||||
this.debugFileWatcher = null;
|
||||
this.lastDebugFilePosition = 0;
|
||||
}
|
||||
|
||||
async getServerStatus() {
|
||||
// Double-check if process is actually running when we think it is
|
||||
if (this.isRunning && this.serverProcess && this.serverProcess.pid) {
|
||||
try {
|
||||
// Use kill(pid, 0) to check if process exists without sending a signal
|
||||
process.kill(this.serverProcess.pid, 0);
|
||||
} catch (error) {
|
||||
// Process doesn't exist anymore - it was killed externally
|
||||
this.addLogLine('warning', 'Server process was terminated externally');
|
||||
this.isRunning = false;
|
||||
this.isReady = false;
|
||||
this.serverProcess = null;
|
||||
this.startTime = null;
|
||||
|
||||
// Reset player stats when server stops
|
||||
this.serverStats.players = 0;
|
||||
this.serverStats.memoryUsage = 0;
|
||||
this.serverStats.cpuUsage = 0;
|
||||
|
||||
this.emit('exit', { code: null, signal: 'external' });
|
||||
// Emit status change immediately
|
||||
this.emit('status', {
|
||||
isRunning: this.isRunning,
|
||||
isReady: this.isReady,
|
||||
uptime: 0,
|
||||
startTime: null,
|
||||
players: 0,
|
||||
memoryUsage: 0,
|
||||
cpuUsage: 0,
|
||||
processId: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always check for externally running Luanti servers if we don't have a running one
|
||||
if (!this.isRunning) {
|
||||
const externalServer = await this.detectExternalLuantiServer();
|
||||
if (externalServer) {
|
||||
this.isRunning = true;
|
||||
this.isReady = true;
|
||||
this.startTime = externalServer.startTime;
|
||||
|
||||
// Try to get player data from debug log for external servers
|
||||
const playerData = await this.getExternalServerPlayerData();
|
||||
this.serverStats.players = playerData.count;
|
||||
|
||||
this.addLogLine('info', `Detected external Luanti server (PID: ${externalServer.pid}, World: ${externalServer.world})`);
|
||||
// Create a mock server process object for tracking
|
||||
this.serverProcess = { pid: externalServer.pid, external: true };
|
||||
console.log('ServerManager: Set serverProcess.external = true');
|
||||
|
||||
// Start monitoring debug file for external server
|
||||
this.startDebugFileMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
isReady: this.isReady,
|
||||
uptime: this.isRunning && this.startTime ? Date.now() - this.startTime : 0,
|
||||
startTime: this.startTime,
|
||||
players: this.serverStats.players,
|
||||
memoryUsage: this.serverStats.memoryUsage,
|
||||
cpuUsage: this.serverStats.cpuUsage,
|
||||
processId: this.serverProcess?.pid || null
|
||||
};
|
||||
}
|
||||
|
||||
async startServer(worldName = null) {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Server is already running');
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure minetest directory exists
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
'--server',
|
||||
'--config', paths.configFile
|
||||
];
|
||||
|
||||
if (worldName && worldName.trim() !== '') {
|
||||
if (!paths.isValidWorldName(worldName)) {
|
||||
throw new Error('Invalid world name');
|
||||
}
|
||||
|
||||
// Check if world exists
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch (error) {
|
||||
throw new Error(`World "${worldName}" does not exist. Please create it first in the Worlds section.`);
|
||||
}
|
||||
|
||||
// Read the world's game configuration
|
||||
const worldConfigPath = path.join(worldPath, 'world.mt');
|
||||
try {
|
||||
const worldConfig = await fs.readFile(worldConfigPath, 'utf8');
|
||||
const gameMatch = worldConfig.match(/gameid\s*=\s*(.+)/);
|
||||
if (gameMatch) {
|
||||
const gameId = gameMatch[1].trim();
|
||||
|
||||
args.push('--gameid', gameId);
|
||||
this.addLogLine('info', `Using game: ${gameId} for world: ${worldName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLogLine('warning', `Could not read world config, using default game: ${error.message}`);
|
||||
}
|
||||
|
||||
args.push('--world', worldPath);
|
||||
} else {
|
||||
// If no world specified, we need to create a default world or let the server create one
|
||||
this.addLogLine('info', 'Starting server without specifying a world. Server will use default world settings.');
|
||||
}
|
||||
|
||||
// Check if minetest/luanti executable exists
|
||||
const executable = await this.findMinetestExecutable();
|
||||
|
||||
this.serverProcess = spawn(executable, args, {
|
||||
cwd: paths.minetestDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
this.isRunning = true;
|
||||
this.isReady = false; // Server started but not ready yet
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Handle process events
|
||||
this.serverProcess.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
this.isRunning = false;
|
||||
this.isReady = false;
|
||||
this.serverProcess = null;
|
||||
});
|
||||
|
||||
this.serverProcess.on('exit', (code, signal) => {
|
||||
this.emit('exit', { code, signal });
|
||||
this.isRunning = false;
|
||||
this.isReady = false;
|
||||
this.serverProcess = null;
|
||||
this.startTime = null;
|
||||
this.stopDebugFileMonitoring();
|
||||
});
|
||||
|
||||
// Handle output streams
|
||||
this.serverProcess.stdout.on('data', (data) => {
|
||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||
lines.forEach(line => this.addLogLine('stdout', line));
|
||||
this.parseServerStats(data.toString());
|
||||
});
|
||||
|
||||
this.serverProcess.stderr.on('data', (data) => {
|
||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||
lines.forEach(line => this.addLogLine('stderr', line));
|
||||
});
|
||||
|
||||
this.emit('started', { pid: this.serverProcess.pid });
|
||||
|
||||
// Start monitoring debug.txt file for server ready messages
|
||||
this.startDebugFileMonitoring();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pid: this.serverProcess.pid,
|
||||
message: `Server started successfully with PID ${this.serverProcess.pid}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
this.isReady = false;
|
||||
this.serverProcess = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopServer(force = false) {
|
||||
if (!this.isRunning || !this.serverProcess) {
|
||||
throw new Error('Server is not running');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.serverProcess && this.isRunning) {
|
||||
// Force kill if graceful shutdown failed
|
||||
this.serverProcess.kill('SIGKILL');
|
||||
resolve({ success: true, message: 'Server force-stopped' });
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
this.serverProcess.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: true, message: 'Server stopped gracefully' });
|
||||
});
|
||||
|
||||
// Try graceful shutdown first
|
||||
if (force) {
|
||||
this.serverProcess.kill('SIGTERM');
|
||||
} else {
|
||||
// Send shutdown command to server
|
||||
try {
|
||||
this.serverProcess.stdin.write('/shutdown\n');
|
||||
} catch (error) {
|
||||
// If stdin fails, use SIGTERM
|
||||
this.serverProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async restartServer(worldName = null) {
|
||||
if (this.isRunning) {
|
||||
await this.stopServer();
|
||||
// Wait a moment for clean shutdown
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
return await this.startServer(worldName);
|
||||
}
|
||||
|
||||
async findGamePath(gameId) {
|
||||
try {
|
||||
// Use the paths utility to find installed games
|
||||
const games = await paths.getInstalledGames();
|
||||
const game = games.find(g => g.name === gameId);
|
||||
|
||||
if (game) {
|
||||
return game.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.addLogLine('warning', `Error finding game path for "${gameId}": ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async findMinetestExecutable() {
|
||||
// Whitelist of allowed executable names to prevent command injection
|
||||
const allowedExecutables = ['luanti', 'minetest', 'minetestserver'];
|
||||
const foundExecutables = [];
|
||||
|
||||
for (const name of allowedExecutables) {
|
||||
try {
|
||||
// Validate executable name against whitelist
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const execPath = await new Promise((resolve, reject) => {
|
||||
// Use spawn instead of exec to avoid command injection
|
||||
const { spawn } = require('child_process');
|
||||
const whichProcess = spawn('which', [name], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
whichProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
whichProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
whichProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(`which command failed: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
whichProcess.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Validate that the returned path is safe
|
||||
if (execPath && path.isAbsolute(execPath)) {
|
||||
foundExecutables.push({ name, path: execPath });
|
||||
this.addLogLine('info', `Found executable: ${name} at ${execPath}`);
|
||||
return execPath; // Return the full path for security
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue to next possibility
|
||||
}
|
||||
}
|
||||
|
||||
// Provide detailed error message
|
||||
const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` +
|
||||
`Searched for: ${allowedExecutables.join(', ')}\n` +
|
||||
`Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`;
|
||||
|
||||
this.addLogLine('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
addLogLine(type, content) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
type,
|
||||
content: content.trim()
|
||||
};
|
||||
|
||||
this.logBuffer.push(logEntry);
|
||||
|
||||
// Keep only the last N lines
|
||||
if (this.logBuffer.length > this.maxLogLines) {
|
||||
this.logBuffer = this.logBuffer.slice(-this.maxLogLines);
|
||||
}
|
||||
|
||||
this.emit('log', logEntry);
|
||||
}
|
||||
|
||||
parseServerStats(output) {
|
||||
// Parse server output for statistics and ready state
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Look for player count
|
||||
const playerMatch = line.match(/(\d+) players? online/i);
|
||||
if (playerMatch) {
|
||||
this.serverStats.players = parseInt(playerMatch[1]);
|
||||
}
|
||||
|
||||
// Look for performance stats if available
|
||||
const memMatch = line.match(/Memory usage: ([\d.]+)MB/i);
|
||||
if (memMatch) {
|
||||
this.serverStats.memoryUsage = parseFloat(memMatch[1]);
|
||||
}
|
||||
|
||||
// Check if server is ready - look for common Luanti server ready messages
|
||||
if (!this.isReady && this.isRunning) {
|
||||
const readyIndicators = [
|
||||
/Server for gameid=".*?" listening on/i,
|
||||
/listening on \[::\]:\d+/i,
|
||||
/listening on 0\.0\.0\.0:\d+/i,
|
||||
/World at \[.*?\]/i,
|
||||
/Server started/i,
|
||||
/Loading environment/i
|
||||
];
|
||||
|
||||
for (const indicator of readyIndicators) {
|
||||
if (indicator.test(line)) {
|
||||
this.isReady = true;
|
||||
this.addLogLine('info', 'Server is now ready to accept connections');
|
||||
console.log(`Server ready detected from line: ${line}`); // Debug log
|
||||
// Emit status change when server becomes ready
|
||||
this.emit('status', {
|
||||
isRunning: this.isRunning,
|
||||
isReady: this.isReady,
|
||||
uptime: this.startTime ? Date.now() - this.startTime : 0,
|
||||
startTime: this.startTime,
|
||||
players: this.serverStats.players,
|
||||
memoryUsage: this.serverStats.memoryUsage,
|
||||
cpuUsage: this.serverStats.cpuUsage,
|
||||
processId: this.serverProcess?.pid || null
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for error conditions that indicate startup failure
|
||||
const errorIndicators = [
|
||||
/ERROR\[Main\]:/i,
|
||||
/FATAL ERROR/i,
|
||||
/Could not find or load game/i,
|
||||
/Failed to/i
|
||||
];
|
||||
|
||||
for (const errorIndicator of errorIndicators) {
|
||||
if (errorIndicator.test(line)) {
|
||||
// Don't mark as ready if we see critical errors
|
||||
this.addLogLine('warning', 'Server startup may have failed - check logs for errors');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('stats', this.serverStats);
|
||||
}
|
||||
|
||||
getLogs(lines = 100) {
|
||||
return this.logBuffer.slice(-lines);
|
||||
}
|
||||
|
||||
getRecentLogs(since = null) {
|
||||
if (!since) {
|
||||
return this.logBuffer.slice(-50);
|
||||
}
|
||||
|
||||
const sinceTime = new Date(since);
|
||||
return this.logBuffer.filter(log =>
|
||||
new Date(log.timestamp) > sinceTime
|
||||
);
|
||||
}
|
||||
|
||||
async sendCommand(command) {
|
||||
if (!this.isRunning || !this.serverProcess) {
|
||||
throw new Error('Server is not running');
|
||||
}
|
||||
|
||||
// Check if this is an external server
|
||||
if (this.serverProcess.external) {
|
||||
throw new Error('Cannot send commands to external servers. Commands can only be sent to servers started through this dashboard.');
|
||||
}
|
||||
|
||||
// Validate and sanitize command
|
||||
const sanitizedCommand = this.validateServerCommand(command);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.serverProcess.stdin.write(sanitizedCommand + '\n');
|
||||
this.addLogLine('info', `Command sent: ${sanitizedCommand}`);
|
||||
resolve({ success: true, message: 'Command sent successfully' });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateServerCommand(command) {
|
||||
if (!command || typeof command !== 'string') {
|
||||
throw new Error('Command must be a non-empty string');
|
||||
}
|
||||
|
||||
// Remove any control characters and limit length
|
||||
const sanitized = command.replace(/[\x00-\x1F\x7F]/g, '').trim();
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
throw new Error('Command cannot be empty after sanitization');
|
||||
}
|
||||
|
||||
if (sanitized.length > 500) {
|
||||
throw new Error('Command too long (max 500 characters)');
|
||||
}
|
||||
|
||||
// Whitelist of allowed command prefixes for safety
|
||||
const allowedCommands = [
|
||||
'/say', '/tell', '/kick', '/ban', '/unban', '/status', '/time', '/weather',
|
||||
'/give', '/teleport', '/tp', '/spawn', '/help', '/list', '/who', '/shutdown',
|
||||
'/stop', '/save-all', '/whitelist', '/op', '/deop', '/gamemode', '/difficulty',
|
||||
'/seed', '/defaultgamemode', '/gamerule', '/reload', '/clear', '/experience',
|
||||
'/xp', '/effect', '/enchant', '/summon', '/kill', '/scoreboard', '/team',
|
||||
'/trigger', '/clone', '/execute', '/fill', '/setblock', '/testforblock',
|
||||
'/blockdata', '/entitydata', '/testfor', '/stats', '/worldborder'
|
||||
];
|
||||
|
||||
// Check if command starts with allowed prefix or is a direct server command
|
||||
const isAllowed = allowedCommands.some(prefix =>
|
||||
sanitized.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
) || /^[a-zA-Z0-9_-]+(\s+[a-zA-Z0-9_.-]+)*$/.test(sanitized);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new Error('Command not allowed or contains invalid characters');
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async getServerInfo() {
|
||||
try {
|
||||
const configExists = await fs.access(paths.configFile).then(() => true).catch(() => false);
|
||||
const debugLogExists = await fs.access(paths.debugFile).then(() => true).catch(() => false);
|
||||
|
||||
let configMtime = null;
|
||||
if (configExists) {
|
||||
const stats = await fs.stat(paths.configFile);
|
||||
configMtime = stats.mtime;
|
||||
}
|
||||
|
||||
return {
|
||||
configFile: {
|
||||
exists: configExists,
|
||||
path: paths.configFile,
|
||||
lastModified: configMtime
|
||||
},
|
||||
debugLog: {
|
||||
exists: debugLogExists,
|
||||
path: paths.debugFile
|
||||
},
|
||||
directories: {
|
||||
minetest: paths.minetestDir,
|
||||
worlds: paths.worldsDir,
|
||||
mods: paths.modsDir
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get server info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
startDebugFileMonitoring() {
|
||||
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
|
||||
|
||||
try {
|
||||
// Get initial file size to start monitoring from the end
|
||||
const stats = fsSync.existsSync(debugFilePath) ? fsSync.statSync(debugFilePath) : null;
|
||||
this.lastDebugFilePosition = stats ? stats.size : 0;
|
||||
|
||||
// Watch for changes to debug.txt
|
||||
this.debugFileWatcher = fsSync.watchFile(debugFilePath, { interval: 500 }, (current, previous) => {
|
||||
if (current.mtime > previous.mtime) {
|
||||
this.readDebugFileChanges(debugFilePath);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.addLogLine('warning', `Could not monitor debug.txt: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
stopDebugFileMonitoring() {
|
||||
if (this.debugFileWatcher) {
|
||||
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
|
||||
fsSync.unwatchFile(debugFilePath);
|
||||
this.debugFileWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
async readDebugFileChanges(debugFilePath) {
|
||||
try {
|
||||
const stats = fsSync.statSync(debugFilePath);
|
||||
if (stats.size > this.lastDebugFilePosition) {
|
||||
const stream = fsSync.createReadStream(debugFilePath, {
|
||||
start: this.lastDebugFilePosition,
|
||||
end: stats.size - 1
|
||||
});
|
||||
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
const lines = buffer.split('\n').filter(line => line.trim());
|
||||
lines.forEach(line => {
|
||||
this.addLogLine('debug-file', line);
|
||||
this.parseServerStats(line); // Parse each line for ready indicators
|
||||
|
||||
// For external servers, also update player count from new log entries
|
||||
if (this.serverProcess?.external) {
|
||||
this.updatePlayerCountFromLogLine(line);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.lastDebugFilePosition = stats.size;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors when reading debug file changes
|
||||
}
|
||||
}
|
||||
|
||||
updatePlayerCountFromLogLine(line) {
|
||||
// Update player count based on join/leave messages in log
|
||||
const joinMatch = line.match(/\[Server\]: (\w+) joined the game/);
|
||||
const leaveMatch = line.match(/\[Server\]: (\w+) left the game/);
|
||||
|
||||
if (joinMatch || leaveMatch) {
|
||||
// Player joined or left - update player data
|
||||
this.getExternalServerPlayerData().then(playerData => {
|
||||
this.serverStats.players = playerData.count;
|
||||
});
|
||||
}
|
||||
}
|
||||
async detectExternalLuantiServer() {
|
||||
try {
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const psProcess = spawn('ps', ['aux'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
|
||||
psProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
psProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
// Look for luanti or minetest server processes (exclude this dashboard process)
|
||||
if ((line.includes('luanti') || line.includes('minetest')) &&
|
||||
(line.includes('--server') || line.includes('--worldname')) &&
|
||||
!line.includes('node app.js')) {
|
||||
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parseInt(parts[1]);
|
||||
|
||||
if (pid && !isNaN(pid)) {
|
||||
// Extract world name from command line
|
||||
let world = 'unknown';
|
||||
const worldNameMatch = line.match(/--worldname\s+(\S+)/);
|
||||
const worldPathMatch = line.match(/--world\s+(\S+)/);
|
||||
|
||||
if (worldNameMatch) {
|
||||
world = worldNameMatch[1];
|
||||
} else if (worldPathMatch) {
|
||||
world = path.basename(worldPathMatch[1]);
|
||||
}
|
||||
|
||||
// Estimate start time (this is rough, but better than nothing)
|
||||
const startTime = Date.now() - 60000; // Assume started 1 minute ago
|
||||
|
||||
resolve({
|
||||
pid: pid,
|
||||
world: world,
|
||||
startTime: startTime
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
psProcess.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getExternalServerPlayerData() {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
|
||||
|
||||
// Read the last 100 lines of debug.txt to find recent player activity
|
||||
const data = await fs.readFile(debugFilePath, 'utf8');
|
||||
const lines = data.split('\n').slice(-100);
|
||||
|
||||
// Look for recent player actions to determine who's online
|
||||
const playerData = new Map(); // Map to store player name -> player info
|
||||
const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5)
|
||||
|
||||
console.log('DEBUG: Looking for players active since:', cutoffTime.toISOString());
|
||||
|
||||
for (const line of lines.reverse()) {
|
||||
// Parse timestamp from log line
|
||||
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):/);
|
||||
if (timestampMatch) {
|
||||
const logTime = new Date(timestampMatch[1]);
|
||||
if (logTime < cutoffTime) break; // Stop looking at older entries
|
||||
|
||||
// Look for player actions with more detail
|
||||
const actionPatterns = [
|
||||
{ pattern: /ACTION\[Server\]: (\w+) (.+)/, type: 'action' },
|
||||
{ pattern: /\[Server\]: (\w+) joined the game/, type: 'joined' },
|
||||
{ pattern: /\[Server\]: (\w+) left the game/, type: 'left' }
|
||||
];
|
||||
|
||||
for (const { pattern, type } of actionPatterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const playerName = match[1];
|
||||
const actionDescription = match[2] || type;
|
||||
|
||||
console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription);
|
||||
|
||||
// Filter out obvious non-player names and false positives
|
||||
if (!playerName.includes('Entity') &&
|
||||
!playerName.includes('SAO') &&
|
||||
!playerName.includes('Explosion') &&
|
||||
playerName !== 'Player' && // Generic "Player" is not a real username
|
||||
playerName !== 'Server' &&
|
||||
playerName !== 'Main' &&
|
||||
playerName.length > 2 && // Too short usernames are likely false positives
|
||||
playerName.length < 20 &&
|
||||
/^[a-zA-Z0-9_]+$/.test(playerName)) {
|
||||
|
||||
console.log('DEBUG: Player passed filters:', playerName);
|
||||
|
||||
// Update player data with most recent activity
|
||||
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
|
||||
let lastAction = actionDescription;
|
||||
|
||||
// Simplify common actions for display
|
||||
if (lastAction.includes('digs ')) {
|
||||
lastAction = 'Mining';
|
||||
} else if (lastAction.includes('places ') || lastAction.includes('puts ')) {
|
||||
lastAction = 'Building';
|
||||
} else if (lastAction.includes('uses ') || lastAction.includes('activates ')) {
|
||||
lastAction = 'Using items';
|
||||
} else if (lastAction.includes('punched ') || lastAction.includes('damage')) {
|
||||
lastAction = 'Combat';
|
||||
} else if (type === 'joined') {
|
||||
lastAction = 'Just joined';
|
||||
} else if (type === 'left') {
|
||||
lastAction = 'Left game';
|
||||
} else {
|
||||
lastAction = 'Active';
|
||||
}
|
||||
|
||||
// Count activities for this player
|
||||
const existingData = playerData.get(playerName) || { activityCount: 0 };
|
||||
|
||||
playerData.set(playerName, {
|
||||
name: playerName,
|
||||
lastSeen: logTime,
|
||||
lastAction: lastAction,
|
||||
activityCount: existingData.activityCount + 1,
|
||||
online: type !== 'left' // Mark as offline if they left
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array of player objects, filtering out players who left
|
||||
const players = Array.from(playerData.values())
|
||||
.filter(player => player.online)
|
||||
.map(player => ({
|
||||
name: player.name,
|
||||
lastSeen: player.lastSeen,
|
||||
lastAction: player.lastAction,
|
||||
activityCount: player.activityCount,
|
||||
online: true
|
||||
}));
|
||||
|
||||
return {
|
||||
count: players.length,
|
||||
players: players
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading debug file for player data:', error);
|
||||
return { count: 0, players: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ServerManager;
|
6
utils/shared-server-manager.js
Normal file
6
utils/shared-server-manager.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const ServerManager = require('./server-manager');
|
||||
|
||||
// Create a single shared instance
|
||||
const sharedServerManager = new ServerManager();
|
||||
|
||||
module.exports = sharedServerManager;
|
63
views/auth/login.ejs
Normal file
63
views/auth/login.ejs
Normal file
@@ -0,0 +1,63 @@
|
||||
<%
|
||||
const body = `
|
||||
<div style="max-width: 400px; margin: 2rem auto;">
|
||||
<div class="card">
|
||||
<div class="card-header" style="text-align: center;">
|
||||
<h2>Login to Luanti Server Manager</h2>
|
||||
<p style="color: var(--text-secondary); margin: 0;">Enter your credentials to access the server management interface</p>
|
||||
</div>
|
||||
|
||||
${typeof error !== 'undefined' ? `
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> ${typeof escapeHtml !== 'undefined' ? escapeHtml(error) : error}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${typeof req !== 'undefined' && req.query.message ? `
|
||||
<div class="alert alert-info">
|
||||
${req.query.message}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="redirect" value="${redirectUrl || '/'}">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-control"
|
||||
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; align-items: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
|
||||
<p>Need an account? Contact an existing administrator to create one for you.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'login', title: title }) %>
|
116
views/auth/register.ejs
Normal file
116
views/auth/register.ejs
Normal file
@@ -0,0 +1,116 @@
|
||||
<%
|
||||
const body = `
|
||||
<div style="max-width: 500px; margin: 2rem auto;">
|
||||
<div class="card">
|
||||
<div class="card-header" style="text-align: center;">
|
||||
<h2>${isFirstUser ? 'Setup Administrator Account' : 'Create Account'}</h2>
|
||||
<p style="color: var(--text-secondary); margin: 0;">
|
||||
${isFirstUser ?
|
||||
'Create the first administrator account for this Luanti server' :
|
||||
'Join this Luanti server management team'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${typeof error !== 'undefined' ? `
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> ${error}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${isFirstUser ? `
|
||||
<div class="alert alert-info">
|
||||
<strong>First User Setup:</strong> You are creating the first administrator account for this server. All users have full admin privileges.
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<form method="POST" action="/register">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<div class="form-group">
|
||||
<label for="username">Username*</label>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-control"
|
||||
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_-]{3,20}"
|
||||
title="3-20 characters, letters, numbers, underscore, or hyphen only"
|
||||
data-validate-name
|
||||
autofocus
|
||||
autocomplete="username">
|
||||
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="password">Password*</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password">
|
||||
<small style="color: var(--text-secondary);">At least 8 characters long</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password*</label>
|
||||
<input type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
class="form-control"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
||||
<a href="/login" class="btn btn-outline">
|
||||
Already have an account?
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
${isFirstUser ? 'Setup Account' : 'Create Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
|
||||
<p>
|
||||
${isFirstUser ?
|
||||
'This will be the primary administrator account.' :
|
||||
'All accounts have full server administration privileges.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Client-side password confirmation validation
|
||||
document.getElementById('confirmPassword').addEventListener('input', function() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = this.value;
|
||||
|
||||
if (password && confirmPassword) {
|
||||
if (password !== confirmPassword) {
|
||||
this.setCustomValidity('Passwords do not match');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('password').addEventListener('input', function() {
|
||||
const confirmPassword = document.getElementById('confirmPassword');
|
||||
if (confirmPassword.value) {
|
||||
confirmPassword.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'register', title: title }) %>
|
687
views/config/index.ejs
Normal file
687
views/config/index.ejs
Normal file
@@ -0,0 +1,687 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>⚙️ Server Configuration</h2>
|
||||
<p>Configure your Luanti server's global settings</p>
|
||||
<div class="config-help">
|
||||
<small class="text-muted">
|
||||
💡 These are global server settings. For world-specific settings, visit
|
||||
<a href="/worlds">🌍 World Management</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="config-header-flex">
|
||||
<h3>⚙️ Server Configuration</h3>
|
||||
<div class="config-status" id="configStatus">
|
||||
<span class="status-indicator saved">✅ Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-description">Configure all server settings below</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="configForm">
|
||||
<div id="configSections">
|
||||
<!-- All configuration sections will be loaded here -->
|
||||
<div class="loading">Loading configuration...</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>💾 Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-success btn-sm btn-block" id="saveBtn">
|
||||
💾 Save Changes
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm btn-block" id="reloadBtn">
|
||||
🔄 Reload
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm btn-block" id="resetBtn">
|
||||
↩️ Reset All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>📝 Configuration File</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Location: <code id="configPath">~/.minetest/minetest.conf</code>
|
||||
</p>
|
||||
<button class="btn btn-outline-info btn-sm btn-block" id="downloadBtn">
|
||||
📄 Download Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>ℹ️ Status</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-info">
|
||||
<div class="info-item">
|
||||
<strong>Data Directory:</strong><br>
|
||||
<small id="currentDataDir">Loading...</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal temporarily removed for debugging -->
|
||||
|
||||
<style>
|
||||
.config-help {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-accent);
|
||||
border-radius: var(--border-radius);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.config-header-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-divider {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Navigation styles removed - now showing all sections */
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.config-status {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-indicator.saved {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-indicator.modified {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-section-header {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-section-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.config-section-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.config-section-header p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.config-section-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setting-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.setting-input.number {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.setting-input.boolean {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.setting-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.setting-default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.setting-validation {
|
||||
color: var(--danger-color);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal styles temporarily removed for debugging */
|
||||
|
||||
.status-info .info-item {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-accent);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.status-info .info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.config-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal media query removed */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Configuration page JavaScript - properly outside template literal
|
||||
let configData = {};
|
||||
let originalConfig = {};
|
||||
let configSections = {};
|
||||
let unsavedChanges = false;
|
||||
|
||||
console.log('Configuration page JavaScript is loading...');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM Content Loaded - Config Page');
|
||||
loadConfiguration();
|
||||
|
||||
// Add event listeners for action buttons
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', saveConfiguration);
|
||||
}
|
||||
|
||||
const reloadBtn = document.getElementById('reloadBtn');
|
||||
if (reloadBtn) {
|
||||
reloadBtn.addEventListener('click', reloadConfiguration);
|
||||
}
|
||||
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', resetSection);
|
||||
}
|
||||
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', downloadConfig);
|
||||
}
|
||||
|
||||
// Warn about unsaved changes
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (unsavedChanges) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
console.log('loadConfiguration function started...');
|
||||
console.log('About to fetch /api/config...');
|
||||
|
||||
const response = await fetch('/api/config');
|
||||
console.log('Fetch response:', response);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Response not ok:', response.status, response.statusText);
|
||||
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
||||
}
|
||||
|
||||
console.log('About to parse JSON...');
|
||||
const data = await response.json();
|
||||
console.log('Configuration data received:', data);
|
||||
console.log('Data keys:', Object.keys(data));
|
||||
|
||||
configData = data.current;
|
||||
originalConfig = { ...data.current };
|
||||
configSections = data.sections;
|
||||
|
||||
console.log('About to render config sections...');
|
||||
renderConfigSections();
|
||||
console.log('Config sections rendered, updating status...');
|
||||
updateStatus('saved');
|
||||
|
||||
// Update status info
|
||||
document.getElementById('currentDataDir').textContent = data.current.data_directory || 'Not configured';
|
||||
document.getElementById('configPath').textContent = (data.current.data_directory || '~/.minetest') + '/minetest.conf';
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
console.error('Error type:', typeof error);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
|
||||
// Show a basic fallback form
|
||||
const sectionsElement = document.getElementById('configSections');
|
||||
console.log('Setting error content to element:', sectionsElement);
|
||||
|
||||
if (sectionsElement) {
|
||||
sectionsElement.innerHTML =
|
||||
'<div class="alert alert-warning">' +
|
||||
'<strong>⚠️ Configuration Loading Failed</strong><br>' +
|
||||
error.message + '<br>' +
|
||||
'Showing basic form instead.' +
|
||||
'</div>';
|
||||
} else {
|
||||
console.error('configSections element not found!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderConfigSections() {
|
||||
const container = document.getElementById('configSections');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Icons for each section
|
||||
const sectionIcons = {
|
||||
'System': '🏗️',
|
||||
'Server': '🖥️',
|
||||
'World': '🌍',
|
||||
'Performance': '⚡',
|
||||
'Security': '🔒',
|
||||
'Network': '🌐',
|
||||
'Advanced': '🔧'
|
||||
};
|
||||
|
||||
for (const [sectionName, section] of Object.entries(configSections)) {
|
||||
// Create section header
|
||||
const sectionHeaderDiv = document.createElement('div');
|
||||
sectionHeaderDiv.className = 'config-section-header';
|
||||
sectionHeaderDiv.innerHTML =
|
||||
'<h3>' + (sectionIcons[sectionName] || '⚙️') + ' ' + sectionName + ' Configuration</h3>' +
|
||||
'<p>' + (section.description || (sectionName + ' configuration settings')) + '</p>';
|
||||
container.appendChild(sectionHeaderDiv);
|
||||
|
||||
// Create section content
|
||||
const sectionDiv = document.createElement('div');
|
||||
sectionDiv.className = 'config-section-content';
|
||||
|
||||
if (section.note) {
|
||||
const noteDiv = document.createElement('div');
|
||||
noteDiv.className = 'alert alert-info';
|
||||
noteDiv.innerHTML = '💡 ' + section.note;
|
||||
sectionDiv.appendChild(noteDiv);
|
||||
}
|
||||
|
||||
for (const [settingKey, setting] of Object.entries(section.settings)) {
|
||||
const settingDiv = document.createElement('div');
|
||||
settingDiv.className = 'setting-group';
|
||||
|
||||
const currentValue = configData[settingKey] !== undefined ?
|
||||
configData[settingKey] : setting.default;
|
||||
|
||||
settingDiv.innerHTML =
|
||||
'<label class="setting-label" for="' + settingKey + '">' +
|
||||
settingKey +
|
||||
'</label>' +
|
||||
'<div class="setting-description">' +
|
||||
setting.description +
|
||||
'</div>' +
|
||||
renderSettingInput(settingKey, setting, currentValue) +
|
||||
'<div class="setting-meta">' +
|
||||
'<span class="setting-default">Default: ' + setting.default + '</span>' +
|
||||
'<span class="setting-type">' + setting.type + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-validation" id="validation-' + settingKey + '"></div>';
|
||||
|
||||
sectionDiv.appendChild(settingDiv);
|
||||
}
|
||||
|
||||
container.appendChild(sectionDiv);
|
||||
}
|
||||
|
||||
// Add event listeners for changes
|
||||
container.addEventListener('input', handleSettingChange);
|
||||
container.addEventListener('change', handleSettingChange);
|
||||
}
|
||||
|
||||
function renderSettingInput(key, setting, value) {
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return '<input type="checkbox" ' +
|
||||
'id="' + key + '" ' +
|
||||
'name="' + key + '" ' +
|
||||
'class="setting-input boolean" ' +
|
||||
(value ? 'checked' : '') + '>';
|
||||
|
||||
case 'number':
|
||||
const step = setting.step || (setting.min !== undefined && setting.min < 1 ? '0.01' : '1');
|
||||
return '<input type="number" ' +
|
||||
'id="' + key + '" ' +
|
||||
'name="' + key + '" ' +
|
||||
'class="setting-input number" ' +
|
||||
'value="' + value + '"' +
|
||||
'step="' + step + '"' +
|
||||
(setting.min !== undefined ? 'min="' + setting.min + '"' : '') +
|
||||
(setting.max !== undefined ? 'max="' + setting.max + '"' : '') + '>';
|
||||
|
||||
case 'text':
|
||||
return '<textarea id="' + key + '" ' +
|
||||
'name="' + key + '" ' +
|
||||
'class="setting-input" ' +
|
||||
'rows="3">' + (value || '') + '</textarea>';
|
||||
|
||||
default: // string
|
||||
return '<input type="text" ' +
|
||||
'id="' + key + '" ' +
|
||||
'name="' + key + '" ' +
|
||||
'class="setting-input" ' +
|
||||
'value="' + (value || '') + '">';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingChange(event) {
|
||||
const input = event.target;
|
||||
const key = input.name;
|
||||
|
||||
if (!key) return;
|
||||
|
||||
let value;
|
||||
if (input.type === 'checkbox') {
|
||||
value = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
value = parseFloat(input.value) || 0;
|
||||
} else {
|
||||
value = input.value;
|
||||
}
|
||||
|
||||
configData[key] = value;
|
||||
|
||||
// Validate setting
|
||||
validateSetting(key, value);
|
||||
|
||||
// Mark as modified
|
||||
if (JSON.stringify(configData) !== JSON.stringify(originalConfig)) {
|
||||
updateStatus('modified');
|
||||
unsavedChanges = true;
|
||||
} else {
|
||||
updateStatus('saved');
|
||||
unsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateSetting(key, value) {
|
||||
const validationElement = document.getElementById('validation-' + key);
|
||||
if (!validationElement) return;
|
||||
|
||||
// Find setting definition
|
||||
let setting = null;
|
||||
for (const section of Object.values(configSections)) {
|
||||
if (section.settings && section.settings[key]) {
|
||||
setting = section.settings[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!setting) return;
|
||||
|
||||
// Validate based on type
|
||||
let isValid = true;
|
||||
let errorMessage = '';
|
||||
|
||||
if (setting.type === 'number') {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
isValid = false;
|
||||
errorMessage = 'Must be a number';
|
||||
} else {
|
||||
if (setting.min !== undefined && num < setting.min) {
|
||||
isValid = false;
|
||||
errorMessage = 'Must be at least ' + setting.min;
|
||||
}
|
||||
if (setting.max !== undefined && num > setting.max) {
|
||||
isValid = false;
|
||||
errorMessage = 'Must be at most ' + setting.max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
validationElement.textContent = '';
|
||||
const input = document.getElementById(key);
|
||||
if (input) input.style.borderColor = '';
|
||||
} else {
|
||||
validationElement.textContent = errorMessage;
|
||||
const input = document.getElementById(key);
|
||||
if (input) input.style.borderColor = 'var(--danger-color)';
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// showSection function removed - now showing all sections at once
|
||||
|
||||
async function saveConfiguration() {
|
||||
try {
|
||||
// Validate all current settings
|
||||
let hasErrors = false;
|
||||
for (const [key, value] of Object.entries(configData)) {
|
||||
if (!validateSetting(key, value)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
updateStatus('error');
|
||||
alert('Please fix validation errors before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus('saving');
|
||||
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: configData })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
originalConfig = { ...configData };
|
||||
unsavedChanges = false;
|
||||
updateStatus('saved');
|
||||
|
||||
// Show success message briefly
|
||||
const statusElement = document.getElementById('configStatus');
|
||||
if (statusElement) {
|
||||
statusElement.innerHTML = '<span class="status-indicator saved">✅ Configuration saved!</span>';
|
||||
setTimeout(() => {
|
||||
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
updateStatus('error');
|
||||
alert('Failed to save configuration: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('error');
|
||||
alert('Failed to save configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConfiguration() {
|
||||
if (unsavedChanges) {
|
||||
if (!confirm('You have unsaved changes. Are you sure you want to reload?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await loadConfiguration();
|
||||
}
|
||||
|
||||
async function resetSection() {
|
||||
if (!confirm('Reset all configuration settings to defaults?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/reset', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
await loadConfiguration();
|
||||
} else {
|
||||
alert('Failed to reset configuration: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to reset configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const data = await response.json();
|
||||
|
||||
// Generate config file content
|
||||
let content = '# Minetest configuration file\\n';
|
||||
content += '# Generated by LuHost\\n\\n';
|
||||
|
||||
for (const [key, value] of Object.entries(data.current)) {
|
||||
content += key + ' = ' + value + '\\n';
|
||||
}
|
||||
|
||||
// Download as file
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'minetest.conf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
alert('Failed to download config: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
const statusElement = document.getElementById('configStatus');
|
||||
if (!statusElement) return;
|
||||
|
||||
switch (status) {
|
||||
case 'saved':
|
||||
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
|
||||
break;
|
||||
case 'modified':
|
||||
statusElement.innerHTML = '<span class="status-indicator modified">⚠️ Modified</span>';
|
||||
break;
|
||||
case 'saving':
|
||||
statusElement.innerHTML = '<span class="status-indicator">💾 Saving...</span>';
|
||||
break;
|
||||
case 'error':
|
||||
statusElement.innerHTML = '<span class="status-indicator error">❌ Error</span>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'config', title: title }) %>
|
429
views/contentdb/index.ejs
Normal file
429
views/contentdb/index.ejs
Normal file
@@ -0,0 +1,429 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>ContentDB Installer</h2>
|
||||
<p>Install mods, games, and texture packs by pasting ContentDB URLs</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Install from URL</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/contentdb/install-url" id="installForm">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<div class="form-group">
|
||||
<label for="packageUrl">ContentDB Package URL*</label>
|
||||
<input type="text"
|
||||
id="packageUrl"
|
||||
name="packageUrl"
|
||||
class="form-control"
|
||||
placeholder="https://content.luanti.org/packages/author/package_name/"
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
Paste any ContentDB package URL or use format: author/package_name
|
||||
</small>
|
||||
<div id="urlValidation" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="locationSelectionGroup">
|
||||
<div class="form-group">
|
||||
<label for="installLocation">Install Location</label>
|
||||
<select name="installLocation" id="installLocation" class="form-control" required>
|
||||
<option value="global">Global</option>
|
||||
<option value="world">Specific World</option>
|
||||
</select>
|
||||
<small class="form-text text-muted" id="locationHelp">Choose where to install this content</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="worldSelectionGroup" style="display: none;">
|
||||
<label for="worldName">Target World</label>
|
||||
<select name="worldName" id="worldName" class="form-control">
|
||||
<option value="">Select a world...</option>
|
||||
<!-- Will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="dependencyGroup">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="installDeps" name="installDeps" checked>
|
||||
<label for="installDeps" class="checkbox-label">
|
||||
Install dependencies automatically
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-text text-muted" id="depsHelp">
|
||||
Recommended: Automatically download and install required dependencies
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success btn-lg" id="installBtn">
|
||||
📦 Install Package
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="installStatus" style="display: none;">
|
||||
<div class="alert" id="statusAlert">
|
||||
<div id="statusMessage"></div>
|
||||
<div id="installProgress" class="mt-2">
|
||||
<div class="spinner"></div>
|
||||
<span>Installing package...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>How to Use</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h4>📋 Step 1: Copy URL</h4>
|
||||
<p>Go to <a href="https://content.luanti.org" target="_blank">content.luanti.org</a> and copy the URL of any content (mods, games, texture packs).</p>
|
||||
|
||||
<h4>📍 Step 2: Auto-Detection</h4>
|
||||
<p><strong>Games</strong> install automatically to games directory.<br>
|
||||
<strong>Mods</strong> let you choose global or world-specific.<br>
|
||||
<strong>Texture packs</strong> install automatically to textures directory.</p>
|
||||
|
||||
<h4>⚡ Step 3: Install</h4>
|
||||
<p>Click install and dependencies will be resolved automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>🔄 Package Updates</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Check for updates to your installed packages:</p>
|
||||
<a href="/contentdb/updates" class="btn btn-primary btn-block">Check for Updates</a>
|
||||
<a href="/contentdb/installed" class="btn btn-outline-primary btn-block">View Installed</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>📝 Supported URL Formats</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="url-examples">
|
||||
<h4>✅ Supported Formats:</h4>
|
||||
<ul>
|
||||
<li><code>https://content.luanti.org/packages/author/package_name/</code></li>
|
||||
<li><code>content.luanti.org/packages/author/package_name/</code></li>
|
||||
<li><code>author/package_name</code> (direct format)</li>
|
||||
</ul>
|
||||
|
||||
<h4>📋 Example URLs:</h4>
|
||||
<ul>
|
||||
<li><strong>Mod:</strong> <code>https://content.luanti.org/packages/VanessaE/basic_materials/</code></li>
|
||||
<li><strong>Game:</strong> <code>https://content.luanti.org/packages/GreenXenith/nodecore/</code></li>
|
||||
<li><strong>Texture Pack:</strong> <code>https://content.luanti.org/packages/author/texture_pack/</code></li>
|
||||
<li><strong>Direct:</strong> <code>VanessaE/basic_materials</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.url-examples ul {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.url-examples li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.url-examples code {
|
||||
background: var(--bg-accent);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#urlValidation {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.validation-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
#installProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const packageUrlInput = document.getElementById('packageUrl');
|
||||
const installLocationSelect = document.getElementById('installLocation');
|
||||
const worldSelectionGroup = document.getElementById('worldSelectionGroup');
|
||||
const worldNameSelect = document.getElementById('worldName');
|
||||
const installForm = document.getElementById('installForm');
|
||||
const installBtn = document.getElementById('installBtn');
|
||||
const installStatus = document.getElementById('installStatus');
|
||||
const urlValidation = document.getElementById('urlValidation');
|
||||
const locationSelectionGroup = document.getElementById('locationSelectionGroup');
|
||||
const dependencyGroup = document.getElementById('dependencyGroup');
|
||||
const locationHelp = document.getElementById('locationHelp');
|
||||
const depsHelp = document.getElementById('depsHelp');
|
||||
|
||||
let currentPackageType = null;
|
||||
|
||||
// Load available worlds
|
||||
loadWorlds();
|
||||
|
||||
// Show/hide world selection based on install location
|
||||
installLocationSelect.addEventListener('change', function() {
|
||||
if (this.value === 'world') {
|
||||
worldSelectionGroup.style.display = 'block';
|
||||
worldNameSelect.required = true;
|
||||
} else {
|
||||
worldSelectionGroup.style.display = 'none';
|
||||
worldNameSelect.required = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time URL validation
|
||||
let validationTimeout;
|
||||
packageUrlInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(() => {
|
||||
validateUrl(this.value);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Form submission
|
||||
installForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const url = packageUrlInput.value.trim();
|
||||
if (!url) {
|
||||
showError('Please enter a package URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show installation status
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = '⏳ Installing...';
|
||||
showStatus('Installing package...', 'info', true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Convert FormData to URLSearchParams for proper encoding
|
||||
for (let [key, value] of formData.entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch('/contentdb/install-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showStatus(result.message + ' ✅', 'success', false);
|
||||
clearForm();
|
||||
|
||||
// Auto-hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
installStatus.style.display = 'none';
|
||||
}, 5000);
|
||||
} else {
|
||||
showStatus(result.error || 'Installation failed', 'error', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Installation error:', error);
|
||||
showStatus('Installation failed: ' + error.message, 'error', false);
|
||||
} finally {
|
||||
installBtn.disabled = false;
|
||||
installBtn.textContent = '📦 Install Package';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadWorlds() {
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
const worlds = await response.json();
|
||||
|
||||
worldNameSelect.innerHTML = '<option value="">Select a world...</option>';
|
||||
worlds.forEach(world => {
|
||||
const option = document.createElement('option');
|
||||
option.value = world.name;
|
||||
option.textContent = world.displayName || world.name;
|
||||
worldNameSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUrl(url) {
|
||||
if (!url.trim()) {
|
||||
urlValidation.innerHTML = '';
|
||||
resetUIForPackageType();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URL client-side for immediate feedback
|
||||
const parsed = parseContentDBUrl(url);
|
||||
|
||||
if (parsed.author && parsed.name) {
|
||||
// Show valid URL format - type detection happens during installation
|
||||
urlValidation.innerHTML = '<span class="validation-success">✅ Valid: ' + parsed.author + '/' + parsed.name + ' (type will be detected during installation)</span>';
|
||||
resetUIForPackageType();
|
||||
} else {
|
||||
urlValidation.innerHTML = '<span class="validation-error">❌ Invalid URL format</span>';
|
||||
resetUIForPackageType();
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDBUrl(url) {
|
||||
// Remove protocol and clean up
|
||||
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
|
||||
|
||||
// Match patterns
|
||||
const patterns = [
|
||||
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
|
||||
/^([^/]+)\\/([^/]+)$/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return { author: match[1], name: match[2] };
|
||||
}
|
||||
}
|
||||
|
||||
return { author: null, name: null };
|
||||
}
|
||||
|
||||
function updateUIForPackageType(packageType, author, name, title) {
|
||||
const typeDisplayName = packageType === 'game' ? 'Game' :
|
||||
packageType === 'txp' ? 'Texture Pack' : 'Mod';
|
||||
const typeEmoji = packageType === 'game' ? '🎮' :
|
||||
packageType === 'txp' ? '🎨' : '📦';
|
||||
|
||||
urlValidation.innerHTML = \`<span class="validation-success">✅ \${typeEmoji} \${typeDisplayName}: \${title || (author + '/' + name)}</span>\`;
|
||||
|
||||
if (packageType === 'game') {
|
||||
// Games go to games directory - no location choice
|
||||
locationSelectionGroup.style.display = 'none';
|
||||
dependencyGroup.style.display = 'none';
|
||||
installBtn.innerHTML = '🎮 Install Game';
|
||||
} else if (packageType === 'txp') {
|
||||
// Texture packs go to textures directory - no location choice
|
||||
locationSelectionGroup.style.display = 'none';
|
||||
dependencyGroup.style.display = 'none';
|
||||
installBtn.innerHTML = '🎨 Install Texture Pack';
|
||||
} else {
|
||||
// Mods can be installed globally or per-world
|
||||
locationSelectionGroup.style.display = 'block';
|
||||
dependencyGroup.style.display = 'block';
|
||||
locationHelp.textContent = 'Choose where to install this mod';
|
||||
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
|
||||
installBtn.innerHTML = '📦 Install Mod';
|
||||
}
|
||||
}
|
||||
|
||||
function resetUIForPackageType() {
|
||||
currentPackageType = null;
|
||||
locationSelectionGroup.style.display = 'block';
|
||||
dependencyGroup.style.display = 'block';
|
||||
installBtn.innerHTML = '📦 Install Package';
|
||||
locationHelp.textContent = 'Choose where to install this content';
|
||||
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
|
||||
}
|
||||
|
||||
function showStatus(message, type, showProgress) {
|
||||
const statusAlert = document.getElementById('statusAlert');
|
||||
const statusMessage = document.getElementById('statusMessage');
|
||||
const installProgress = document.getElementById('installProgress');
|
||||
|
||||
// Map alert types to Bootstrap classes
|
||||
const alertClass = type === 'error' ? 'alert-danger' : type === 'info' ? 'alert-info' : 'alert-' + type;
|
||||
|
||||
statusAlert.className = 'alert ' + alertClass;
|
||||
statusMessage.textContent = message;
|
||||
installProgress.style.display = showProgress ? 'flex' : 'none';
|
||||
installStatus.style.display = 'block';
|
||||
|
||||
// Scroll to status for better visibility
|
||||
installStatus.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showStatus(message, 'danger', false);
|
||||
}
|
||||
|
||||
window.clearForm = function() {
|
||||
installForm.reset();
|
||||
urlValidation.innerHTML = '';
|
||||
installStatus.style.display = 'none';
|
||||
worldSelectionGroup.style.display = 'none';
|
||||
worldNameSelect.required = false;
|
||||
resetUIForPackageType();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
308
views/contentdb/installed.ejs
Normal file
308
views/contentdb/installed.ejs
Normal file
@@ -0,0 +1,308 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>📦 Installed Packages</h2>
|
||||
<p>Manage your installed mods, games, and texture packs</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>📊 Statistics</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.total_packages || 0}</strong>
|
||||
<span>Total Packages</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.global_packages || 0}</strong>
|
||||
<span>Global Mods</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.world_packages || 0}</strong>
|
||||
<span>World-specific</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.worlds_with_packages || 0}</strong>
|
||||
<span>Worlds with Mods</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>🔍 Filter Packages</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/contentdb/installed"
|
||||
class="btn ${selectedLocation === 'all' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
|
||||
All Locations
|
||||
</a>
|
||||
<a href="/contentdb/installed?location=global"
|
||||
class="btn ${selectedLocation === 'global' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
|
||||
Global Mods
|
||||
</a>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">World-specific filters coming soon</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
${packages.length === 0 ? `
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3>📭 No Packages Installed</h3>
|
||||
<p>You haven't installed any packages yet from ContentDB.</p>
|
||||
<a href="/contentdb" class="btn btn-primary">
|
||||
Browse ContentDB
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="packages-grid">
|
||||
${packages.map(pkg => `
|
||||
<div class="card package-card">
|
||||
<div class="card-header">
|
||||
<div class="package-title">
|
||||
<h4>${pkg.title || pkg.name}</h4>
|
||||
<small class="text-muted">by ${pkg.author}</small>
|
||||
</div>
|
||||
<div class="package-actions">
|
||||
<span class="badge badge-${pkg.package_type === 'game' ? 'success' : pkg.package_type === 'txp' ? 'warning' : 'primary'}">
|
||||
${pkg.package_type || 'mod'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="package-details">
|
||||
<p class="package-description">
|
||||
${pkg.short_description || 'No description available.'}
|
||||
</p>
|
||||
|
||||
<div class="package-meta">
|
||||
<div class="meta-item">
|
||||
<strong>Location:</strong>
|
||||
<span class="location-badge ${pkg.install_location === 'global' ? 'global' : 'world'}">
|
||||
${pkg.install_location === 'global' ? 'Global' : pkg.install_location.replace('world:', '')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<strong>Version:</strong>
|
||||
<span>${pkg.version || 'Unknown'}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<strong>Installed:</strong>
|
||||
<span>${new Date(pkg.installed_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${pkg.dependencies && pkg.dependencies.length > 0 ? `
|
||||
<div class="dependencies">
|
||||
<strong>Dependencies (${pkg.dependencies.length}):</strong>
|
||||
<div class="dep-list">
|
||||
${pkg.dependencies.map(dep =>
|
||||
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
|
||||
).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="package-actions">
|
||||
${pkg.contentdb_url ? `
|
||||
<a href="\${pkg.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
|
||||
View on ContentDB
|
||||
</a>
|
||||
` : ''}
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
onclick="checkForUpdate('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
|
||||
Check Update
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
onclick="uninstallPackage('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.packages-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.package-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.package-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-title h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.package-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.package-meta {
|
||||
background: var(--bg-accent);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.location-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.location-badge.global {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.location-badge.world {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dependencies {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dep-list {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.package-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.package-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.package-actions .btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function checkForUpdate(author, name, location) {
|
||||
alert('Update checking feature coming soon!');
|
||||
// TODO: Implement update checking
|
||||
}
|
||||
|
||||
function uninstallPackage(author, name, location) {
|
||||
if (confirm('Are you sure you want to uninstall ' + name + '?')) {
|
||||
alert('Uninstall feature coming soon!');
|
||||
// TODO: Implement package uninstallation
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
22
views/contentdb/package.ejs
Normal file
22
views/contentdb/package.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>Package Details</h2>
|
||||
<p>View and install content from ContentDB</p>
|
||||
</div>
|
||||
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<p>Package details will be displayed here.</p>
|
||||
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
22
views/contentdb/popular.ejs
Normal file
22
views/contentdb/popular.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>Popular Content</h2>
|
||||
<p>Most downloaded mods and games from ContentDB</p>
|
||||
</div>
|
||||
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<p>Popular content will be displayed here.</p>
|
||||
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
22
views/contentdb/recent.ejs
Normal file
22
views/contentdb/recent.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>Recent Content</h2>
|
||||
<p>Recently added mods and games from ContentDB</p>
|
||||
</div>
|
||||
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<p>Recent content will be displayed here.</p>
|
||||
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
305
views/contentdb/updates.ejs
Normal file
305
views/contentdb/updates.ejs
Normal file
@@ -0,0 +1,305 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>🔄 Package Updates</h2>
|
||||
<p>Check and install updates for your packages</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>📊 Update Status</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="stat-item">
|
||||
<strong>${installedCount || 0}</strong>
|
||||
<span>Total Packages</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${updateCount || 0}</strong>
|
||||
<span>Updates Available</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${installedCount - updateCount || 0}</strong>
|
||||
<span>Up to Date</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>⚡ Quick Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${updateCount > 0 ? `
|
||||
<button class="btn btn-success btn-block" onclick="updateAllPackages()">
|
||||
📦 Update All (${updateCount})
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
|
||||
🔄 Refresh Check
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
|
||||
🔄 Check Again
|
||||
</button>
|
||||
`}
|
||||
<a href="/contentdb/installed" class="btn btn-outline-secondary btn-block">
|
||||
📦 View All Installed
|
||||
</a>
|
||||
<a href="/contentdb" class="btn btn-outline-secondary btn-block">
|
||||
🌐 Browse ContentDB
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
${updateCount === 0 ? `
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3>✅ All Packages Up to Date!</h3>
|
||||
<p>All your installed packages are running the latest versions.</p>
|
||||
<div class="emoji-large">🎉</div>
|
||||
<p class="text-muted">
|
||||
${installedCount === 0 ?
|
||||
'You haven\\'t installed any packages yet.' :
|
||||
\`Checked \${installedCount} package\${installedCount !== 1 ? 's' : ''}.\`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="updates-list">
|
||||
${updates.map(update => `
|
||||
<div class="card update-card">
|
||||
<div class="card-header">
|
||||
<div class="update-title">
|
||||
<h4>${update.latest.package.title || update.installed.name}</h4>
|
||||
<small class="text-muted">by ${update.installed.author}</small>
|
||||
</div>
|
||||
<div class="update-badge">
|
||||
<span class="badge badge-warning">Update Available</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="version-comparison">
|
||||
<div class="version-item current">
|
||||
<div class="version-label">Current Version</div>
|
||||
<div class="version-value">${update.installed.version}</div>
|
||||
<div class="version-date">
|
||||
Installed: ${new Date(update.installed.installed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-arrow">➜</div>
|
||||
<div class="version-item latest">
|
||||
<div class="version-label">Latest Version</div>
|
||||
<div class="version-value">${update.latest.release.title}</div>
|
||||
<div class="version-date">
|
||||
Released: ${new Date(update.latest.release.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="package-location">
|
||||
<strong>Location:</strong>
|
||||
<span class="location-badge ${update.installed.install_location === 'global' ? 'global' : 'world'}">
|
||||
${update.installed.install_location === 'global' ? 'Global' : update.installed.install_location.replace('world:', '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="update-actions">
|
||||
<button class="btn btn-success"
|
||||
onclick="updatePackage('${update.installed.author}', '${update.installed.name}', '${update.installed.install_location}')">
|
||||
📦 Update Now
|
||||
</button>
|
||||
<a href="https://content.luanti.org/packages/${update.installed.author}/${update.installed.name}/"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary">
|
||||
View on ContentDB
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.emoji-large {
|
||||
font-size: 3rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.updates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.update-card {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.update-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.update-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.update-title h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.version-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-accent);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.version-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.version-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.version-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.current .version-value {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.latest .version-value {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.package-location {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.location-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.location-badge.global {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.location-badge.world {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.version-comparison {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.version-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.update-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.update-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function updatePackage(author, name, location) {
|
||||
alert('Update functionality coming soon!');
|
||||
// TODO: Implement individual package update
|
||||
}
|
||||
|
||||
function updateAllPackages() {
|
||||
if (!confirm('Update all packages? This may take a while.')) {
|
||||
return;
|
||||
}
|
||||
alert('Bulk update functionality coming soon!');
|
||||
// TODO: Implement bulk package update
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
142
views/dashboard.ejs
Normal file
142
views/dashboard.ejs
Normal file
@@ -0,0 +1,142 @@
|
||||
<%
|
||||
const body = `
|
||||
<!-- Dashboard Statistics -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.worlds}</div>
|
||||
<div class="stat-label">Worlds</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.mods}</div>
|
||||
<div class="stat-label">Mods</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
<span id="server-status" class="status status-stopped">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-label">Server Status</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: 1rem; word-break: break-all;">
|
||||
${stats.minetestDir}
|
||||
</div>
|
||||
<div class="stat-label">Minetest Directory</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Quick Actions</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<div class="card" style="margin: 0;">
|
||||
<h3>World Management</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Create and manage your game worlds
|
||||
</p>
|
||||
<div class="btn-group">
|
||||
<a href="/worlds" class="btn btn-primary">Manage Worlds</a>
|
||||
<a href="/worlds/new" class="btn btn-outline">Create World</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin: 0;">
|
||||
<h3>Extensions</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Manage games, mods, and texture packs
|
||||
</p>
|
||||
<div class="btn-group">
|
||||
<a href="/extensions" class="btn btn-primary">Manage Extensions</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin: 0;">
|
||||
<h3>Server Control</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Start, stop, and monitor your server
|
||||
</p>
|
||||
<div class="btn-group">
|
||||
<a href="/server" class="btn btn-primary">Server Console</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin: 0;">
|
||||
<h3>ContentDB Browser</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Discover new content on ContentDB
|
||||
</p>
|
||||
<div class="btn-group">
|
||||
<a href="/contentdb" class="btn btn-primary">Browse ContentDB</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="card">
|
||||
<h3>System Information</h3>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Platform</strong></td>
|
||||
<td>${systemInfo.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Architecture</strong></td>
|
||||
<td>${systemInfo.arch}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Node.js Version</strong></td>
|
||||
<td>${systemInfo.nodeVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Minetest Directory</strong></td>
|
||||
<td style="word-break: break-all;">${stats.minetestDir}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Server Uptime</strong></td>
|
||||
<td id="server-uptime">N/A</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Process ID</strong></td>
|
||||
<td id="server-pid">N/A</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity (placeholder for future implementation) -->
|
||||
<div class="card">
|
||||
<h3>Recent Activity</h3>
|
||||
<div class="empty-state">
|
||||
<p>Activity logging will be implemented in a future update.</p>
|
||||
<small>This will show recent world changes, mod installations, and server events.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/shared-status.js"></script>
|
||||
<script>
|
||||
// Set current page for navigation
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
// Add any dashboard-specific JavaScript here
|
||||
console.log('Dashboard loaded');
|
||||
|
||||
// Update page context for navigation
|
||||
if (window.luantiWebServer) {
|
||||
window.luantiWebServer.currentPage = 'dashboard';
|
||||
}
|
||||
|
||||
// Load server status using shared function
|
||||
updateServerStatus('server-status');
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('layout', { body: body, currentPage: 'dashboard', title: title }) %>
|
41
views/error.ejs
Normal file
41
views/error.ejs
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error | Luanti Server Manager</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card" style="max-width: 600px; margin: 2rem auto; text-align: center;">
|
||||
<div style="font-size: 4rem; color: var(--danger-color); margin-bottom: 1rem;">
|
||||
⚠️
|
||||
</div>
|
||||
|
||||
<h1 style="color: var(--danger-color); margin-bottom: 1rem;">
|
||||
<%= error %>
|
||||
</h1>
|
||||
|
||||
<% if (typeof message !== 'undefined' && message) { %>
|
||||
<div class="alert alert-danger" style="text-align: left;">
|
||||
<strong>Details:</strong> <%= message %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="btn-group" style="margin-top: 2rem;">
|
||||
<a href="javascript:history.back()" class="btn btn-secondary">
|
||||
Go Back
|
||||
</a>
|
||||
<a href="/" class="btn btn-primary">
|
||||
Return to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
<p>If this problem persists, please check the server logs or restart the application.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
693
views/extensions/index.ejs
Normal file
693
views/extensions/index.ejs
Normal file
@@ -0,0 +1,693 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>🧩 Extensions</h2>
|
||||
<p>Manage games, mods, and texture packs for your Luanti server</p>
|
||||
</div>
|
||||
|
||||
<div class="extensions-layout">
|
||||
<!-- Sidebar -->
|
||||
<div class="extensions-sidebar">
|
||||
<!-- Overview Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>📊 Overview</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="overview-stats">
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.games || 0}</strong>
|
||||
<span>Games</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${(statistics.global_packages || 0) + (statistics.local_mods || 0)}</strong>
|
||||
<span>Mods</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong>${statistics.total_packages || 0}</strong>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Install Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>⚡ Quick Install</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="quickInstallForm">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<div class="form-group mb-3">
|
||||
<label for="quickPackageUrl">Package URL or Author/Name:</label>
|
||||
<input type="text" id="quickPackageUrl" name="packageUrl" class="form-control"
|
||||
placeholder="e.g., mesecons or author/name" required>
|
||||
<div id="quickUrlValidation"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3" id="quickLocationGroup">
|
||||
<label for="quickInstallLocation">Install Location:</label>
|
||||
<select id="quickInstallLocation" name="installLocation" class="form-control">
|
||||
<option value="global">Global</option>
|
||||
<option value="world">Specific World</option>
|
||||
</select>
|
||||
|
||||
<select id="quickWorldName" name="worldName" class="form-control mt-2" style="display: none;">
|
||||
<option value="">Select a world...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label>
|
||||
<input type="checkbox" name="installDeps" value="on">
|
||||
Install Dependencies
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="quickInstallBtn" class="btn btn-primary btn-block">
|
||||
📦 Install
|
||||
</button>
|
||||
|
||||
<div id="quickInstallStatus" style="display: none;">
|
||||
<div id="quickStatusAlert" class="alert mt-2">
|
||||
<span id="quickStatusMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="extensions-main">
|
||||
<div class="extensions-header">
|
||||
<div class="extensions-tabs">
|
||||
<button class="tab-btn active" onclick="filterExtensions('all')">
|
||||
All (${allContent.length})
|
||||
</button>
|
||||
<button class="tab-btn" onclick="filterExtensions('game')">
|
||||
Games (${allContent.filter(c => (c.package_type || c.type) === 'game').length})
|
||||
</button>
|
||||
<button class="tab-btn" onclick="filterExtensions('mod')">
|
||||
Mods (${allContent.filter(c => (c.package_type || c.type) === 'mod').length})
|
||||
</button>
|
||||
<button class="tab-btn" onclick="filterExtensions('txp')">
|
||||
Texture Packs (${allContent.filter(c => (c.package_type || c.type) === 'txp').length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${allContent.length === 0 ? `
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3>📭 No Extensions Installed</h3>
|
||||
<p>Install games, mods, and texture packs from ContentDB or add them manually.</p>
|
||||
<a href="/contentdb" class="btn btn-primary">
|
||||
Browse ContentDB
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="extensions-grid" id="extensionsGrid">
|
||||
${allContent.map(ext => {
|
||||
const type = ext.package_type || ext.type;
|
||||
const typeIcon = type === 'game' ? '🎮' : type === 'txp' ? '🎨' : '📦';
|
||||
const typeBadge = type === 'game' ? 'success' : type === 'txp' ? 'warning' : 'primary';
|
||||
const sourceIcon = ext.source === 'contentdb' ? '🌐' : '📁';
|
||||
|
||||
return `
|
||||
<div class="card extension-card" data-type="${type}">
|
||||
<div class="card-header">
|
||||
<div class="extension-title">
|
||||
<h4>${typeIcon} ${ext.title || ext.name}</h4>
|
||||
<small class="text-muted">
|
||||
${sourceIcon} ${ext.author || 'Local'}
|
||||
${ext.source === 'contentdb' ? '(ContentDB)' : '(Local)'}
|
||||
</small>
|
||||
</div>
|
||||
<div class="extension-badges">
|
||||
<span class="badge badge-${typeBadge}">
|
||||
${type === 'txp' ? 'Texture Pack' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="extension-details">
|
||||
<p class="extension-description">
|
||||
${ext.short_description || ext.description || 'No description available.'}
|
||||
</p>
|
||||
|
||||
<div class="extension-meta">
|
||||
<div class="meta-item">
|
||||
<strong>Location:</strong>
|
||||
<span class="location-badge ${ext.install_location === 'global' || ext.location === 'global' ? 'global' : 'world'}">
|
||||
${ext.install_location === 'global' || ext.location === 'global' ? 'Global' :
|
||||
ext.install_location ? ext.install_location.replace('world:', '') : ext.location || 'Games'}
|
||||
</span>
|
||||
</div>
|
||||
${ext.version ? `
|
||||
<div class="meta-item">
|
||||
<strong>Version:</strong>
|
||||
<span>${ext.version}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="meta-item">
|
||||
<strong>Modified:</strong>
|
||||
<span>${ext.installed_at ? new Date(ext.installed_at).toLocaleDateString() :
|
||||
new Date(ext.lastModified).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${ext.dependencies && ext.dependencies.length > 0 ? `
|
||||
<div class="dependencies">
|
||||
<strong>Dependencies (${ext.dependencies.length}):</strong>
|
||||
<div class="dep-list">
|
||||
${ext.dependencies.map(dep =>
|
||||
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
|
||||
).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="extension-actions">
|
||||
${ext.contentdb_url ? `
|
||||
<a href="${ext.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
|
||||
View on ContentDB
|
||||
</a>
|
||||
` : ''}
|
||||
${ext.source === 'contentdb' ? `
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
onclick="checkForUpdate('${ext.author}', '${ext.name}')">
|
||||
Check Update
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
onclick="uninstallExtension('${ext.name}', '${type}', '${ext.install_location || ext.location}')">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.extensions-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.extensions-sidebar {
|
||||
flex: 0 0 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.extensions-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extensions-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.extensions-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: var(--bg-accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.extensions-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.extension-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.extension-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.extension-card[data-type="game"] {
|
||||
border-left: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.extension-card[data-type="mod"] {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.extension-card[data-type="txp"] {
|
||||
border-left: 4px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.extension-title h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.extension-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.extension-meta {
|
||||
background: var(--bg-accent);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.extension-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.location-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.location-badge.global {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.location-badge.world {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dependencies {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dep-list {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.extensions-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extensions-sidebar {
|
||||
flex: none;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.extensions-main {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.extensions-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.extension-actions .btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const quickInstallForm = document.getElementById('quickInstallForm');
|
||||
const quickPackageUrlInput = document.getElementById('quickPackageUrl');
|
||||
const quickInstallLocationSelect = document.getElementById('quickInstallLocation');
|
||||
const quickWorldNameSelect = document.getElementById('quickWorldName');
|
||||
const quickLocationGroup = document.getElementById('quickLocationGroup');
|
||||
const quickInstallBtn = document.getElementById('quickInstallBtn');
|
||||
const quickInstallStatus = document.getElementById('quickInstallStatus');
|
||||
const quickUrlValidation = document.getElementById('quickUrlValidation');
|
||||
|
||||
// Load available worlds
|
||||
loadWorlds();
|
||||
|
||||
// Show/hide world selection
|
||||
quickInstallLocationSelect.addEventListener('change', function() {
|
||||
if (this.value === 'world') {
|
||||
quickWorldNameSelect.style.display = 'block';
|
||||
quickWorldNameSelect.required = true;
|
||||
} else {
|
||||
quickWorldNameSelect.style.display = 'none';
|
||||
quickWorldNameSelect.required = false;
|
||||
}
|
||||
});
|
||||
|
||||
// URL validation
|
||||
let validationTimeout;
|
||||
quickPackageUrlInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(() => {
|
||||
validateUrl(this.value);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Quick install form submission
|
||||
quickInstallForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const url = quickPackageUrlInput.value.trim();
|
||||
if (!url) {
|
||||
showQuickStatus('Please enter a package URL', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
quickInstallBtn.disabled = true;
|
||||
quickInstallBtn.textContent = '⏳ Installing...';
|
||||
showQuickStatus('Installing package...', 'info');
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch('/extensions/install-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showQuickStatus(result.message + ' ✅', 'success');
|
||||
quickInstallForm.reset();
|
||||
quickWorldNameSelect.style.display = 'none';
|
||||
quickWorldNameSelect.required = false;
|
||||
|
||||
// Reload page after 2 seconds to show new extension
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
// Handle specific validation errors with better messaging
|
||||
if (result.type === 'invalid_installation_target' && result.packageType === 'game') {
|
||||
showQuickStatus('❌ ' + result.error, 'warning');
|
||||
} else {
|
||||
showQuickStatus(result.error || 'Installation failed', 'danger');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Installation error:', error);
|
||||
showQuickStatus('Installation failed: ' + error.message, 'danger');
|
||||
} finally {
|
||||
quickInstallBtn.disabled = false;
|
||||
quickInstallBtn.textContent = '📦 Install';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadWorlds() {
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
const worlds = await response.json();
|
||||
|
||||
quickWorldNameSelect.innerHTML = '<option value="">Select a world...</option>';
|
||||
worlds.forEach(world => {
|
||||
const option = document.createElement('option');
|
||||
option.value = world.name;
|
||||
option.textContent = world.displayName || world.name;
|
||||
quickWorldNameSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUrl(url) {
|
||||
if (!url.trim()) {
|
||||
quickUrlValidation.innerHTML = '';
|
||||
resetLocationOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseContentDBUrl(url);
|
||||
|
||||
if (parsed.author && parsed.name) {
|
||||
quickUrlValidation.innerHTML = '<small class="text-info">🔄 Checking package...</small>';
|
||||
|
||||
try {
|
||||
// Check package type via API
|
||||
const response = await fetch('/api/contentdb/package-info', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ author: parsed.author, name: parsed.name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const packageInfo = await response.json();
|
||||
const packageType = packageInfo.type || 'mod';
|
||||
|
||||
if (packageType === 'game') {
|
||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Game: ' + parsed.author + '/' + parsed.name + '</small>';
|
||||
restrictLocationOptionsForGame();
|
||||
} else {
|
||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ ' + packageType.charAt(0).toUpperCase() + packageType.slice(1) + ': ' + parsed.author + '/' + parsed.name + '</small>';
|
||||
resetLocationOptions();
|
||||
}
|
||||
} else {
|
||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
|
||||
resetLocationOptions();
|
||||
}
|
||||
} catch (error) {
|
||||
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
|
||||
resetLocationOptions();
|
||||
}
|
||||
} else {
|
||||
quickUrlValidation.innerHTML = '<small class="text-danger">❌ Invalid URL format</small>';
|
||||
resetLocationOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function restrictLocationOptionsForGame() {
|
||||
// For games, only allow global installation
|
||||
quickInstallLocationSelect.innerHTML = '<option value="global">Global (Games are shared across all worlds)</option>';
|
||||
quickInstallLocationSelect.disabled = true;
|
||||
quickWorldNameSelect.style.display = 'none';
|
||||
quickWorldNameSelect.required = false;
|
||||
|
||||
// Add explanation
|
||||
const existingWarning = document.getElementById('game-warning');
|
||||
if (!existingWarning) {
|
||||
const warning = document.createElement('div');
|
||||
warning.id = 'game-warning';
|
||||
warning.className = 'alert alert-info mt-2';
|
||||
warning.innerHTML = '<small><strong>ℹ️ Note:</strong> Games are installed globally and shared across all worlds. To use this game, create a new world and select it during world creation.</small>';
|
||||
quickLocationGroup.appendChild(warning);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLocationOptions() {
|
||||
// Reset to normal options
|
||||
quickInstallLocationSelect.innerHTML =
|
||||
'<option value="global">Global</option>' +
|
||||
'<option value="world">Specific World</option>';
|
||||
quickInstallLocationSelect.disabled = false;
|
||||
|
||||
// Remove warning if it exists
|
||||
const warning = document.getElementById('game-warning');
|
||||
if (warning) {
|
||||
warning.remove();
|
||||
}
|
||||
|
||||
// Reset world selection based on current value
|
||||
if (quickInstallLocationSelect.value === 'world') {
|
||||
quickWorldNameSelect.style.display = 'block';
|
||||
quickWorldNameSelect.required = true;
|
||||
} else {
|
||||
quickWorldNameSelect.style.display = 'none';
|
||||
quickWorldNameSelect.required = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDBUrl(url) {
|
||||
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
|
||||
|
||||
const patterns = [
|
||||
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
|
||||
/^([^/]+)\\/([^/]+)$/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return { author: match[1], name: match[2] };
|
||||
}
|
||||
}
|
||||
|
||||
return { author: null, name: null };
|
||||
}
|
||||
|
||||
function showQuickStatus(message, type) {
|
||||
const statusAlert = document.getElementById('quickStatusAlert');
|
||||
const statusMessage = document.getElementById('quickStatusMessage');
|
||||
|
||||
const alertClass = 'alert-' + type;
|
||||
statusAlert.className = 'alert mt-2 ' + alertClass;
|
||||
statusMessage.textContent = message;
|
||||
quickInstallStatus.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
function filterExtensions(type) {
|
||||
const cards = document.querySelectorAll('.extension-card');
|
||||
const tabs = document.querySelectorAll('.tab-btn');
|
||||
|
||||
// Update active tab
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Filter cards
|
||||
cards.forEach(card => {
|
||||
if (type === 'all') {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
const cardType = card.getAttribute('data-type');
|
||||
card.style.display = cardType === type ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkForUpdate(author, name) {
|
||||
alert('Update checking feature coming soon!');
|
||||
// TODO: Implement update checking
|
||||
}
|
||||
|
||||
function uninstallExtension(name, type, location) {
|
||||
if (confirm('Are you sure you want to remove ' + name + '?\\n\\nThis will permanently delete the extension files.')) {
|
||||
alert('Uninstall feature coming soon!');
|
||||
// TODO: Implement extension removal
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'extensions', title: title }) %>
|
109
views/layout.ejs
Normal file
109
views/layout.ejs
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= title %> | LuHost</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem;">
|
||||
<div style="text-align: left;">
|
||||
<h1>LuHost</h1>
|
||||
<p>Hosting Luanti made easy</p>
|
||||
</div>
|
||||
|
||||
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated && typeof user !== 'undefined') { %>
|
||||
<div style="text-align: right;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">
|
||||
Welcome, <strong><%= user.username %></strong>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span id="connection-status" class="status status-running">Connected</span>
|
||||
<a href="/logout" class="btn btn-sm btn-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div style="margin-top: 1rem;">
|
||||
<span id="connection-status" class="status status-running">Connected</span>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation (only show when authenticated) -->
|
||||
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated) { %>
|
||||
<nav class="nav">
|
||||
<div class="nav-item">
|
||||
<a href="/" class="nav-link <%= currentPage === 'dashboard' ? 'active' : '' %>">
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/worlds" class="nav-link <%= currentPage === 'worlds' ? 'active' : '' %>">
|
||||
Worlds
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
|
||||
Extensions
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/server" class="nav-link <%= currentPage === 'server' ? 'active' : '' %>">
|
||||
Server
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
|
||||
Configuration
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/users" class="nav-link <%= currentPage === 'users' ? 'active' : '' %>">
|
||||
Users
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<% } %>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
<%- body %>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer style="margin-top: 2rem; padding: 2rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">
|
||||
<p><a href="https://git.medlab.host/Modpol/luhost" target="_blank" style="color: var(--primary-color);">LuHost</a> |
|
||||
<a href="https://luanti.org" target="_blank" style="color: var(--primary-color);">Luanti</a> |
|
||||
<a href="https://content.luanti.org" target="_blank" style="color: var(--primary-color);">ContentDB</a>
|
||||
</p>
|
||||
<p>A project of the <a href="https://www.colorado.edu/lab/medlab/" target="_blank" style="color: var(--primary-color);">Media Economies Design Lab</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Socket.IO -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
|
||||
<!-- Main JavaScript -->
|
||||
<script src="/static/js/main.js"></script>
|
||||
|
||||
<!-- Page-specific scripts -->
|
||||
<% if (typeof scripts !== 'undefined') { %>
|
||||
<% scripts.forEach(function(script) { %>
|
||||
<script src="/static/js/<%= script %>"></script>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
|
||||
<!-- Inline scripts -->
|
||||
<% if (typeof inlineScript !== 'undefined') { %>
|
||||
<script>
|
||||
<%- inlineScript %>
|
||||
</script>
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
318
views/server/index.ejs
Normal file
318
views/server/index.ejs
Normal file
@@ -0,0 +1,318 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>🖥️ Server Management</h2>
|
||||
<p>Monitor and control your Luanti server</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card server-status-card">
|
||||
<div class="card-header">
|
||||
<h3>📊 Server Status</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-indicator" id="serverStatus">
|
||||
<div class="status-light offline" id="statusLight"></div>
|
||||
<span id="statusText">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div class="server-stats" id="serverStats">
|
||||
<div class="stat-item">
|
||||
<strong id="uptime">--</strong>
|
||||
<span>Uptime</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong id="playerCount">--</strong>
|
||||
<span>Players Online</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<strong id="memoryUsage">--</strong>
|
||||
<span>Memory Usage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>🎮 Server Controls</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="control-group">
|
||||
<label for="worldSelect">Choose world:</label>
|
||||
<select id="worldSelect" class="form-control">
|
||||
<option value="">Use server defaults (no specific world)</option>
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="server-controls">
|
||||
<button id="startBtn" class="btn btn-success btn-block">
|
||||
▶️ Start Server
|
||||
</button>
|
||||
<button id="stopBtn" class="btn btn-danger btn-block" disabled>
|
||||
⏹️ Stop Server
|
||||
</button>
|
||||
<button id="restartBtn" class="btn btn-warning btn-block" disabled>
|
||||
🔄 Restart Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>⚙️ Quick Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/config" class="btn btn-outline-primary btn-block">
|
||||
⚙️ Server Configuration
|
||||
</a>
|
||||
<a href="/worlds" class="btn btn-outline-secondary btn-block">
|
||||
🌍 World Configuration
|
||||
</a>
|
||||
<a href="/extensions" class="btn btn-outline-secondary btn-block">
|
||||
🧩 Manage Extensions
|
||||
</a>
|
||||
<button id="downloadBtn" class="btn btn-outline-info btn-block">
|
||||
📁 Download Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3>📋 Server Console</h3>
|
||||
<div>
|
||||
<button id="clearBtn" class="btn btn-outline-secondary btn-sm">Clear</button>
|
||||
<button id="autoScrollBtn" class="btn btn-outline-primary btn-sm">
|
||||
<span id="autoScrollText">Auto-scroll: ON</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="console" id="serverConsole">
|
||||
<div class="console-content" id="consoleContent">
|
||||
<div class="log-entry info">
|
||||
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
|
||||
<span class="message">Console ready. Start server to see logs.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-input" id="consoleInputGroup" style="display: none;">
|
||||
<div class="input-group">
|
||||
<input type="text" id="consoleInput" class="form-control"
|
||||
placeholder="Enter server command (e.g., /say Hello World)">
|
||||
<div class="input-group-append">
|
||||
<button id="sendBtn" class="btn btn-primary">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>👥 Online Players</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="playersList">
|
||||
<p class="text-muted">No players online</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.server-status-card .card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-light.online {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.status-light.offline {
|
||||
background: var(--danger-color);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status-light.starting {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-accent);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.console {
|
||||
background: #1a1a1a;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
height: 400px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
color: #ffffff;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #666666 #2a2a2a;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb {
|
||||
background: #666666;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #888888;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-corner {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.25rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.log-entry.stdout {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.log-entry.stderr {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: #74c0fc;
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #868e96;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.console-input {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.console-input .form-control {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.console-input .form-control:focus {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.server-controls {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.console {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'server', title: title }) %>
|
114
views/users/index.ejs
Normal file
114
views/users/index.ejs
Normal file
@@ -0,0 +1,114 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>User Management</h2>
|
||||
<a href="/users/new" class="btn btn-success">Create New User</a>
|
||||
</div>
|
||||
|
||||
${typeof req !== 'undefined' && req.query.created ? `
|
||||
<div class="alert alert-success">
|
||||
User "${req.query.created}" created successfully!
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${typeof req !== 'undefined' && req.query.deleted ? `
|
||||
<div class="alert alert-info">
|
||||
User deleted successfully.
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${typeof req !== 'undefined' && req.query.error ? `
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> ${req.query.error}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Feudal Authority:</strong> Only you can create new user accounts. All users have full administrator privileges over the Luanti server.
|
||||
</div>
|
||||
|
||||
${users.length === 0 ? `
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">👥</div>
|
||||
<h3>No Users Found</h3>
|
||||
<p>This shouldn't happen since you're logged in. Please report this issue.</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Created</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${user.username}</strong>
|
||||
${user.id === 1 ? '<span class="status" style="background: #e8f5e8; color: #2e7d32; margin-left: 0.5rem;">Founder</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<small>${formatDate(user.created_at)}</small>
|
||||
</td>
|
||||
<td>
|
||||
${user.last_login ? `<small>${formatDate(user.last_login)}</small>` : '<small style="color: var(--text-secondary);">Never</small>'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
${user.id !== 1 ? `
|
||||
<form method="POST" action="/users/delete/${user.id}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirmDelete('user', '${user.username}')">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
` : `
|
||||
<span class="btn btn-sm btn-secondary" style="cursor: not-allowed;" title="Cannot delete founder account">Protected</span>
|
||||
`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Authority & Permissions</h3>
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div>
|
||||
<h4 style="color: var(--primary-color); margin-bottom: 0.5rem;">🏰 Feudal System</h4>
|
||||
<p>This server uses an "implicit feudalism" security model:</p>
|
||||
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
|
||||
<li>Only existing administrators can create new accounts</li>
|
||||
<li>The founder account (first user) cannot be deleted</li>
|
||||
<li>All users have equal administrative privileges</li>
|
||||
<li>No public registration - authority must be granted</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="color: var(--success-color); margin-bottom: 0.5rem;">👑 Administrative Powers</h4>
|
||||
<p>Every user account can:</p>
|
||||
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
|
||||
<li>Manage worlds (create, configure, delete)</li>
|
||||
<li>Install and manage mods</li>
|
||||
<li>Browse and install from ContentDB</li>
|
||||
<li>Control the Luanti server (start, stop, restart)</li>
|
||||
<li>Modify server configuration</li>
|
||||
<li>Create additional user accounts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>
|
106
views/users/new.ejs
Normal file
106
views/users/new.ejs
Normal file
@@ -0,0 +1,106 @@
|
||||
<%
|
||||
const body = `
|
||||
<div style="max-width: 500px; margin: 2rem auto;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Create New Administrator</h2>
|
||||
<p style="color: var(--text-secondary); margin: 0;">
|
||||
Grant administrative access to a new user
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${typeof error !== 'undefined' ? `
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> ${error}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Authority Note:</strong> This user will have full administrative privileges over the Luanti server, including the ability to create additional accounts.
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/users/create">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<div class="form-group">
|
||||
<label for="username">Username*</label>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-control"
|
||||
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_-]{3,20}"
|
||||
title="3-20 characters, letters, numbers, underscore, or hyphen only"
|
||||
data-validate-name
|
||||
autofocus
|
||||
autocomplete="username">
|
||||
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="password">Password*</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password">
|
||||
<small style="color: var(--text-secondary);">At least 8 characters long</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password*</label>
|
||||
<input type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
class="form-control"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
||||
<a href="/users" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Create Administrator
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
|
||||
<p>This user will be able to perform all server management tasks and create additional accounts.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Client-side password confirmation validation
|
||||
document.getElementById('confirmPassword').addEventListener('input', function() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = this.value;
|
||||
|
||||
if (password && confirmPassword) {
|
||||
if (password !== confirmPassword) {
|
||||
this.setCustomValidity('Passwords do not match');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('password').addEventListener('input', function() {
|
||||
const confirmPassword = document.getElementById('confirmPassword');
|
||||
if (confirmPassword.value) {
|
||||
confirmPassword.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>
|
Reference in New Issue
Block a user