Spaces:
Sleeping
Sleeping
Download backend/sync-manager.js from techedstudios/smp-manager: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/techedstudios/smp-manager/resolve/main/backend/sync-manager.js
- Command line
-
hf download hf://spaces/techedstudios/smp-manager/backend/sync-manager.js
-
curl -L -o sync-manager.js https://huggingface.co/spaces/techedstudios/smp-manager/resolve/main/backend/sync-manager.js
15.4 kB
| 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; | |
| } | |
| } | |