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>
365 lines
12 KiB
JavaScript
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; |