Files
LuHost/routes/contentdb.js
Nathan Schneider 3aed09b60f 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>
2025-08-23 17:32:37 -06:00

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;