142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|