/** * Backup manager: creates zip archives of worlds, plugins, or config. * Uses the `archiver` package. */ import archiver from "archiver"; import * as fs from "node:fs"; import * as path from "node:path"; import { db } from "@/lib/db"; import { backups } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; export type BackupType = "worlds" | "plugins" | "config" | "full"; const BACKUPS_DIR = process.env.BACKUPS_PATH ?? path.join(process.cwd(), "backups"); /** Ensure the backups directory exists. */ function ensureBackupsDir(): void { fs.mkdirSync(BACKUPS_DIR, { recursive: true }); } /** * Create a backup archive of the specified type. * Returns the backup record ID. */ export async function createBackup( type: BackupType, triggeredBy: string, ): Promise { ensureBackupsDir(); const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server"; const id = nanoid(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `backup-${type}-${timestamp}.zip`; const filePath = path.join(BACKUPS_DIR, fileName); // Insert pending record await db.insert(backups).values({ id, name: fileName, type, size: 0, path: filePath, createdAt: Date.now(), status: "running", triggeredBy, }); try { await archiveBackup(type, mcPath, filePath); const stat = fs.statSync(filePath); await db .update(backups) .set({ status: "completed", size: stat.size }) .where(eq(backups.id, id)); } catch (err) { await db .update(backups) .set({ status: "failed" }) .where(eq(backups.id, id)); throw err; } return id; } function archiveBackup( type: BackupType, mcPath: string, outPath: string, ): Promise { return new Promise((resolve, reject) => { const output = fs.createWriteStream(outPath); const archive = archiver("zip", { zlib: { level: 6 } }); output.on("close", resolve); archive.on("error", reject); archive.pipe(output); const dirsToArchive: { src: string; name: string }[] = []; if (type === "worlds" || type === "full") { // Common world directory names for (const dir of ["world", "world_nether", "world_the_end"]) { const p = path.join(mcPath, dir); if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: dir }); } } if (type === "plugins" || type === "full") { const p = path.join(mcPath, "plugins"); if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: "plugins" }); } if (type === "config" || type === "full") { // Config files at server root for (const file of [ "server.properties", "ops.json", "whitelist.json", "banned-players.json", "banned-ips.json", "eula.txt", "spigot.yml", "paper.yml", "bukkit.yml", ]) { const p = path.join(mcPath, file); if (fs.existsSync(p)) archive.file(p, { name: `config/${file}` }); } } for (const { src, name } of dirsToArchive) { archive.directory(src, name); } archive.finalize(); }); } /** Delete a backup file and its DB record. */ export async function deleteBackup(id: string): Promise { const record = await db .select() .from(backups) .where(eq(backups.id, id)) .get(); if (!record) throw new Error("Backup not found"); if (record.path && fs.existsSync(record.path)) { fs.unlinkSync(record.path); } await db.delete(backups).where(eq(backups.id, id)); } /** List all backups from the DB. */ export async function listBackups() { return db.select().from(backups).orderBy(backups.createdAt); }