Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

141
lib/backup/manager.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* 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);
}