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>
332 lines
10 KiB
JavaScript
332 lines
10 KiB
JavaScript
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; |