Files
LuHost/routes/extensions.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

365 lines
12 KiB
JavaScript

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;