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>
This commit is contained in:
332
utils/contentdb.js
Normal file
332
utils/contentdb.js
Normal file
@@ -0,0 +1,332 @@
|
||||
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;
|
Reference in New Issue
Block a user