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;