import { uploadFile, downloadFile, listFiles, createRepo, deleteFile } from "@huggingface/hub"; import { doc, getDoc, setDoc } from "firebase/firestore"; import { db } from "./firebase-config.js"; import fs from "fs"; import path from "path"; import AdmZip from "adm-zip"; // ─── Config ─────────────────────────────────────────────────────────────────── const HF_OWNER = process.env.SPACE_AUTHOR_NAME || "techedstudios"; const MAX_BACKUPS = 5; // Keep last 5 backups (unlimited space on HF!) function getHFToken() { return process.env.HF_TOKEN; } function getRepoId(serverId) { const sanitizedId = serverId .replace(/\./g, '-') .replace(/[^a-zA-Z0-9-_\.]/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); return `${HF_OWNER}/mc-server-data-${sanitizedId}`.toLowerCase(); } // ─── Ensure HF Dataset exists ───────────────────────────────────────────────── async function ensureDataset(serverId, onLog) { const repoId = getRepoId(serverId); const token = getHFToken(); try { await createRepo({ repo: { type: "dataset", name: repoId }, accessToken: token, private: false, }); onLog(serverId, `[HF] Created public dataset: ${repoId}`); } catch (e) { // 409 = already exists, that's fine if (!e.message?.includes("409") && !e.message?.includes("already")) { onLog(serverId, `[HF] Dataset already exists or note: ${e.message}`); } } return repoId; } // ─── Compress directory to Buffer (skips heavy/temp files) ─────────────────── function compressDirectoryToBuffer(sourceDir, skipExtra = []) { const zip = new AdmZip(); const skipNames = new Set(["playit", "playit.exe", "downloads", ".git", "tmp", "node_modules", "logs", "cache", "libraries", ".paper-remapped", "versions", ...skipExtra]); function addDir(dirPath, zipPath) { const entries = fs.readdirSync(dirPath); for (const entry of entries) { if (skipNames.has(entry)) continue; const fullPath = path.join(dirPath, entry); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { addDir(fullPath, path.join(zipPath, entry)); } else { if (stat.size > 200 * 1024 * 1024) continue; const content = fs.readFileSync(fullPath); zip.addFile(path.join(zipPath, entry).replace(/\\/g, "/"), content); } } } addDir(sourceDir, ""); return zip.toBuffer(); } // ─── Compress only the plugins folder to Buffer ─────────────────────────────── function compressPluginsFolderToBuffer(serverPath) { const zip = new AdmZip(); const pluginsDir = path.join(serverPath, 'plugins'); const modsDir = path.join(serverPath, 'mods'); function addDir(dirPath, zipPrefix) { if (!fs.existsSync(dirPath)) return; const entries = fs.readdirSync(dirPath); for (const entry of entries) { const fullPath = path.join(dirPath, entry); try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { addDir(fullPath, `${zipPrefix}/${entry}`); } else { if (stat.size > 150 * 1024 * 1024) continue; // skip files >150MB const content = fs.readFileSync(fullPath); zip.addFile(`${zipPrefix}/${entry}`, content); } } catch(e) {} } } addDir(pluginsDir, 'plugins'); addDir(modsDir, 'mods'); return zip.toBuffer(); } // ─── Decompress Buffer to directory (overwrite=true) ───────────────────────── function decompressBufferToDir(buffer, targetDir) { if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); const zip = new AdmZip(buffer); zip.extractAllTo(targetDir, true); } // ─── Upload a Buffer to HF Dataset as a file ────────────────────────────────── async function uploadBufferToHF(repoId, token, hfPath, buffer, commitMessage) { const blob = new Blob([buffer]); await uploadFile({ repo: { type: "dataset", name: repoId }, accessToken: token, file: { path: hfPath, content: blob, }, commitTitle: commitMessage, }); } // ─── Download a file from HF Dataset as Buffer (Bypasses CDN Cache) ─────────── async function downloadBufferFromHF(repoId, token, hfPath) { // Use raw fetch to bypass CDN caching with a cache-busting query parameter const url = `https://huggingface.co/datasets/${repoId}/resolve/main/${hfPath}?t=${Date.now()}`; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) throw new Error(`File not found or failed to fetch ${hfPath} in ${repoId}`); const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } // ─── List backup timestamps from HF Dataset ─────────────────────────────────── async function listBackupTimestamps(repoId, token) { const timestamps = []; try { for await (const entry of listFiles({ repo: { type: "dataset", name: repoId }, path: "backups", accessToken: token, })) { // Each backup is stored as backups/{timestamp}.zip if (entry.type === "file" && entry.path.endsWith(".zip")) { const name = path.basename(entry.path, ".zip"); const ts = parseInt(name); if (!isNaN(ts)) timestamps.push(ts); } } } catch (e) { // backups/ folder may not exist yet } return timestamps .filter(ts => ts < Date.now() + 86400000) // Ignore impossible future dates (e.g. 2286 bug) .sort((a, b) => a - b); // ascending (oldest first) } // ───────────────────────────────────────────────────────────────────────────── // PUBLIC: Backup server to HuggingFace Dataset // ───────────────────────────────────────────────────────────────────────────── export async function backupServer(serverId, serverPath, onLog, serverData) { if (!fs.existsSync(serverPath) || fs.readdirSync(serverPath).length === 0) { onLog(serverId, `[BACKUP] Server path empty or missing. Skipping backup.`); return false; } try { const token = getHFToken(); const repoId = await ensureDataset(serverId, onLog); onLog(serverId, `[BACKUP] Compressing server files...`); const zipBuffer = compressDirectoryToBuffer(serverPath); const sizeMB = (zipBuffer.length / (1024 * 1024)).toFixed(2); onLog(serverId, `[BACKUP] Compression complete. Size: ${sizeMB} MB`); const timestamp = Date.now(); const hfPath = `backups/${timestamp}.zip`; onLog(serverId, `[BACKUP] Uploading to HuggingFace Dataset (${repoId})...`); await uploadBufferToHF(repoId, token, hfPath, zipBuffer, `Backup at ${new Date(timestamp).toISOString()}`); onLog(serverId, `[BACKUP] ✅ Upload complete → hf://datasets/${repoId}/${hfPath}`); // ── Always save a dedicated plugins-backup.zip (so plugins survive restores) ── try { const pluginsZip = compressPluginsFolderToBuffer(serverPath); await uploadBufferToHF(repoId, token, 'plugins-backup.zip', pluginsZip, `Plugin backup at ${new Date(timestamp).toISOString()}`); onLog(serverId, `[BACKUP] 🔌 Plugins backup saved separately (plugins-backup.zip).`); } catch(e) { onLog(serverId, `[BACKUP] Warning: Plugins backup failed: ${e.message}`); } // Upload metadata.json to the root of the dataset so autoRecoverServers can read it later if (serverData) { try { const metaBuffer = Buffer.from(JSON.stringify(serverData, null, 2), 'utf-8'); await uploadBufferToHF(repoId, token, 'metadata.json', metaBuffer, `Update server metadata`); onLog(serverId, `[BACKUP] Server metadata (RAM, version, etc) saved to Dataset.`); } catch (e) { onLog(serverId, `[BACKUP] Warning: Failed to save metadata.json to Dataset: ${e.message}`); } } // Sync backup metadata to Firestore (just timestamps, tiny text data) try { const metaRef = doc(db, `backupMeta/${serverId}`); const existing = (await getDoc(metaRef)).data() || {}; const allTimestamps = [...(existing.timestamps || []), timestamp].sort((a, b) => a - b); await setDoc(metaRef, { timestamps: allTimestamps, lastBackup: new Date(timestamp).toISOString() }); } catch (e) { onLog(serverId, `[BACKUP] Note: Firestore metadata sync failed: ${e.message}`); } // Prune old backups on HF (keep latest MAX_BACKUPS) try { const allTs = await listBackupTimestamps(repoId, token); if (allTs.length > MAX_BACKUPS) { const toDelete = allTs.slice(0, allTs.length - MAX_BACKUPS); for (const ts of toDelete) { onLog(serverId, `[BACKUP] Pruning old backup: ${ts}.zip`); await deleteFile({ repo: { type: "dataset", name: repoId }, path: `backups/${ts}.zip`, accessToken: token, commitTitle: `Prune old backup ${ts}`, }); } onLog(serverId, `[BACKUP] Pruned ${toDelete.length} old backup(s).`); } } catch (pruneErr) { onLog(serverId, `[BACKUP] Warning: Pruning failed: ${pruneErr.message}`); } return true; } catch (err) { onLog(serverId, `[BACKUP] ERROR: ${err.message}`); console.error(err); return false; } } // PUBLIC: Backup ONLY plugins to HF immediately (called after plugin install) // ───────────────────────────────────────────────────────────────────────────── export async function backupPluginsOnly(serverId, serverPath, onLog) { try { const token = getHFToken(); const repoId = await ensureDataset(serverId, onLog); const pluginsZip = compressPluginsFolderToBuffer(serverPath); await uploadBufferToHF(repoId, token, 'plugins-backup.zip', pluginsZip, `Plugin install backup`); onLog(serverId, `[BACKUP] 🔌 Plugin saved to cloud (plugins-backup.zip updated).`); return true; } catch(e) { onLog(serverId, `[BACKUP] ⚠️ Plugin cloud save failed: ${e.message}`); return false; } } // PUBLIC: Backup ONLY configs (ops, whitelist) to HF immediately export async function backupConfigsOnly(serverId, serverPath, onLog) { try { const token = getHFToken(); const repoId = getRepoId(serverId); // Create an in-memory zip containing only config files const AdmZip = (await import('adm-zip')).default; const zip = new AdmZip(); let added = false; const configsToSave = ['ops.json', 'whitelist.json', 'banned-players.json', 'banned-ips.json', 'server.properties']; for (const file of configsToSave) { const p = path.join(serverPath, file); if (fs.existsSync(p)) { zip.addLocalFile(p); added = true; } } if (added) { const zipBuffer = zip.toBuffer(); await uploadBufferToHF(repoId, token, 'configs-backup.zip', zipBuffer, `Real-time configs auto-save`); onLog(serverId, `[BACKUP] 💾 Real-time config auto-save triggered (ops/whitelist saved to cloud).`); } } catch (e) { onLog(serverId, `[BACKUP] ⚠️ Config auto-save failed: ${e.message}`); } } // ───────────────────────────────────────────────────────────────────────────── // PUBLIC: Restore server from HuggingFace Dataset // ───────────────────────────────────────────────────────────────────────────── export async function restoreServer(serverId, serverPath, onLog) { try { const token = getHFToken(); const repoId = getRepoId(serverId); onLog(serverId, `[RESTORE] Checking Firestore & HuggingFace Dataset for backups...`); let allTs = []; try { const metaSnap = await getDoc(doc(db, 'backupMeta', serverId)); if (metaSnap.exists() && metaSnap.data().timestamps) { allTs = metaSnap.data().timestamps; } } catch(e) {} // Fallback to Hugging Face API if Firestore is empty if (allTs.length === 0) { allTs = await listBackupTimestamps(repoId, token); } else { allTs = allTs.filter(ts => ts < Date.now() + 86400000).sort((a, b) => a - b); } if (allTs.length === 0) { onLog(serverId, `[RESTORE] No backups found on HuggingFace. Starting fresh.`); return false; } const latestTs = allTs[allTs.length - 1]; const hfPath = `backups/${latestTs}.zip`; onLog(serverId, `[RESTORE] Found ${allTs.length} backup(s). Restoring latest: ${new Date(latestTs).toLocaleString()}`); // ── Step 1: Download + extract main world backup ────────────────────────── onLog(serverId, `[RESTORE] Downloading world backup from HuggingFace...`); const zipBuffer = await downloadBufferFromHF(repoId, token, hfPath); const sizeMB = (zipBuffer.length / (1024 * 1024)).toFixed(2); onLog(serverId, `[RESTORE] Download complete (${sizeMB} MB). Extracting...`); decompressBufferToDir(zipBuffer, serverPath); onLog(serverId, `[RESTORE] ✅ World backup extracted.`); // ── Step 2: ALWAYS restore dedicated plugins-backup.zip on top ─────────── // plugins-backup.zip is the single source of truth — updated on every install try { onLog(serverId, `[RESTORE] 🔌 Restoring plugins from dedicated plugins backup...`); const pluginsZip = await downloadBufferFromHF(repoId, token, 'plugins-backup.zip'); decompressBufferToDir(pluginsZip, serverPath); onLog(serverId, `[RESTORE] ✅ Plugins restored from plugins-backup.zip`); } catch(e) { onLog(serverId, `[RESTORE] ℹ️ No dedicated plugins backup found yet. Using plugins from world backup.`); } // ── Step 3: Restore real-time configs if they exist ─────────── try { const configsZip = await downloadBufferFromHF(repoId, token, 'configs-backup.zip'); decompressBufferToDir(configsZip, serverPath); onLog(serverId, `[RESTORE] 💾 Real-time configs (ops, whitelist) restored successfully.`); } catch(e) { // It's okay if this doesn't exist yet } onLog(serverId, `[RESTORE] ✅ Server files restored from HuggingFace backup.`); return true; } catch (err) { onLog(serverId, `[RESTORE] ERROR: ${err.message}`); console.error(err); return false; } }