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:
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;
|
Reference in New Issue
Block a user