const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); const archiver = require('archiver'); const yauzl = require('yauzl'); const { promisify } = require('util'); class ContentDBClient { constructor() { this.baseURL = 'https://content.luanti.org/api'; this.client = axios.create({ baseURL: this.baseURL, timeout: 30000, headers: { 'User-Agent': 'LuHost/1.0', 'Accept': 'application/json' }, validateStatus: (status) => { // Only treat 200-299 as success, but don't throw on 404 return (status >= 200 && status < 300) || status === 404; } }); } // Search packages (mods, games, texture packs) async searchPackages(query = '', type = '', sort = 'score', order = 'desc', limit = 20, offset = 0) { try { const params = { q: query, type: type, // mod, game, txp (texture pack) sort: sort, // score, name, created_at, approved_at, downloads order: order, // asc, desc limit: Math.min(limit, 50), // API limit offset: offset }; const response = await this.client.get('/packages/', { params }); return response.data; } catch (error) { throw new Error(`Failed to search ContentDB: ${error.message}`); } } // Get package details async getPackage(author, name) { try { const response = await this.client.get(`/packages/${author}/${name}/`); // Ensure we got JSON back if (typeof response.data !== 'object') { throw new Error('Invalid response format from ContentDB'); } return response.data; } catch (error) { if (error.response?.status === 404) { throw new Error('Package not found'); } // Handle cases where the response isn't JSON if (error.message.includes('JSON') || error.message.includes('Unexpected token')) { throw new Error('ContentDB returned invalid data format'); } throw new Error(`Failed to get package details: ${error.message}`); } } // Get package releases async getPackageReleases(author, name) { try { const response = await this.client.get(`/packages/${author}/${name}/releases/`); return response.data; } catch (error) { throw new Error(`Failed to get package releases: ${error.message}`); } } // Download package async downloadPackage(author, name, targetPath, version = null) { try { // Get package info first const packageInfo = await this.getPackage(author, name); // Get releases to find download URL const releases = await this.getPackageReleases(author, name); if (!releases || releases.length === 0) { throw new Error('No releases found for this package'); } // Find the specified version or use the latest let release; if (version) { release = releases.find(r => r.id === version || r.title === version); if (!release) { throw new Error(`Version ${version} not found`); } } else { // Use the first release (should be latest) release = releases[0]; } if (!release.url) { throw new Error('No download URL found for this release'); } // Construct full download URL if needed let downloadUrl = release.url; if (downloadUrl.startsWith('/')) { downloadUrl = 'https://content.luanti.org' + downloadUrl; } // Download the package const downloadResponse = await axios.get(downloadUrl, { responseType: 'stream', timeout: 120000, // 2 minutes for download headers: { 'User-Agent': 'LuHost/1.0' } }); // Create target directory await fs.mkdir(targetPath, { recursive: true }); // If it's a zip file, extract it if (release.url.endsWith('.zip')) { const tempZipPath = path.join(targetPath, 'temp.zip'); // Save zip file temporarily const writer = require('fs').createWriteStream(tempZipPath); downloadResponse.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); // Extract zip file await this.extractZipFile(tempZipPath, targetPath); // Remove temp zip file await fs.unlink(tempZipPath); } else { // For non-zip files, save directly const fileName = path.basename(release.url) || 'download'; const filePath = path.join(targetPath, fileName); const writer = require('fs').createWriteStream(filePath); downloadResponse.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); } return { package: packageInfo, release: release, downloadPath: targetPath }; } catch (error) { throw new Error(`Failed to download package: ${error.message}`); } } // Extract zip file async extractZipFile(zipPath, targetPath) { return new Promise((resolve, reject) => { yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { if (err) { reject(err); return; } zipfile.readEntry(); zipfile.on('entry', async (entry) => { const entryPath = path.join(targetPath, entry.fileName); // Ensure the entry path is within target directory (security) const normalizedPath = path.normalize(entryPath); if (!normalizedPath.startsWith(path.normalize(targetPath))) { zipfile.readEntry(); return; } if (/\/$/.test(entry.fileName)) { // Directory entry try { await fs.mkdir(normalizedPath, { recursive: true }); zipfile.readEntry(); } catch (error) { reject(error); } } else { // File entry zipfile.openReadStream(entry, (err, readStream) => { if (err) { reject(err); return; } // Ensure parent directory exists const parentDir = path.dirname(normalizedPath); fs.mkdir(parentDir, { recursive: true }) .then(() => { const writeStream = require('fs').createWriteStream(normalizedPath); readStream.pipe(writeStream); writeStream.on('finish', () => { zipfile.readEntry(); }); writeStream.on('error', reject); }) .catch(reject); }); } }); zipfile.on('end', () => { resolve(); }); zipfile.on('error', reject); }); }); } // Get popular packages async getPopularPackages(type = '', limit = 10) { return this.searchPackages('', type, 'downloads', 'desc', limit, 0); } // Get recently updated packages async getRecentPackages(type = '', limit = 10) { return this.searchPackages('', type, 'approved_at', 'desc', limit, 0); } // Check for updates for installed packages async checkForUpdates(installedPackages) { const updates = []; for (const pkg of installedPackages) { try { // Try to find the package on ContentDB // This requires matching local package names to ContentDB packages // which might not always be straightforward // For now, we'll implement a basic search-based approach const searchResults = await this.searchPackages(pkg.name, '', 'score', 'desc', 5); if (searchResults && searchResults.length > 0) { // Try to find exact match const match = searchResults.find(result => result.name.toLowerCase() === pkg.name.toLowerCase() || result.title.toLowerCase() === pkg.name.toLowerCase() ); if (match) { const releases = await this.getPackageReleases(match.author, match.name); if (releases && releases.length > 0) { updates.push({ local: pkg, remote: match, latestRelease: releases[0], hasUpdate: true // We could implement version comparison here }); } } } } catch (error) { // Skip packages that can't be found or checked console.warn(`Could not check updates for ${pkg.name}:`, error.message); } } return updates; } // Get package dependencies async getPackageDependencies(author, name) { try { const packageInfo = await this.getPackage(author, name); return { hard_dependencies: packageInfo.hard_dependencies || [], optional_dependencies: packageInfo.optional_dependencies || [] }; } catch (error) { throw new Error(`Failed to get dependencies: ${error.message}`); } } // Install package with dependencies async installPackageWithDeps(author, name, targetBasePath, resolveDeps = true) { const installResults = { main: null, dependencies: [], errors: [] }; try { // Install main package const mainPackagePath = path.join(targetBasePath, name); const mainResult = await this.downloadPackage(author, name, mainPackagePath); installResults.main = mainResult; // Install dependencies if requested if (resolveDeps) { const deps = await this.getPackageDependencies(author, name); for (const dep of deps.hard_dependencies) { try { const depPath = path.join(targetBasePath, dep.name); const depResult = await this.downloadPackage(dep.author, dep.name, depPath); installResults.dependencies.push(depResult); } catch (error) { installResults.errors.push(`Failed to install dependency ${dep.name}: ${error.message}`); } } } return installResults; } catch (error) { installResults.errors.push(error.message); return installResults; } } } module.exports = ContentDBClient;