smp-manager / backend /sync-manager.js
techedstudios
fix compression and view distance
5344922
Raw History Blame Contribute Delete
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;
}
}