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>
529 lines
17 KiB
JavaScript
529 lines
17 KiB
JavaScript
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; |