feat(webui): backup run/history/download/delete API routes

This commit is contained in:
m4
2026-08-11 11:20:39 +08:00
parent c7d6c65130
commit 7bd43a39a0
3 changed files with 194 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import { Readable } from "node:stream";
import { type NextRequest, NextResponse } from "next/server";
import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor";
import { isCrossOrigin } from "@/lib/server/workspace";
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
import { backupFilePath, isValidBackupName } from "@/lib/server/backup";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ file: string }> };
// Explicit union: inferred object-literal returns get normalized with
// `prop?: undefined` members, which defeats `in` narrowing downstream.
type Resolved =
| { response: NextResponse }
| { actor: Actor; file: string; path: string };
async function resolve(request: NextRequest, params: Params): Promise<Resolved> {
if (isCrossOrigin(request)) {
return {
response: NextResponse.json(
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
{ status: 403, headers: NO_STORE }
),
};
}
const actor = requireActor(request);
requireAdmin(actor);
const { file } = await params.params;
if (!isValidBackupName(file)) {
return { response: NextResponse.json(
{ code: "INVALID_REQUEST", message: "Invalid backup file name." },
{ status: 400, headers: NO_STORE }
) };
}
return { actor, file, path: backupFilePath(file) };
}
export async function GET(request: NextRequest, params: Params) {
try {
const resolved = await resolve(request, params);
if ("response" in resolved) return resolved.response;
if (!fs.existsSync(resolved.path)) {
return new NextResponse("Not found.", { status: 404, headers: NO_STORE });
}
const stream = Readable.toWeb(fs.createReadStream(resolved.path)) as ReadableStream;
return new NextResponse(stream, {
headers: {
"Content-Type": "application/gzip",
"Content-Disposition": `attachment; filename="${resolved.file}"`,
},
});
} catch (error) {
return routeErrorResponse(error);
}
}
export async function DELETE(request: NextRequest, params: Params) {
try {
const resolved = await resolve(request, params);
if ("response" in resolved) return resolved.response;
if (!fs.existsSync(resolved.path)) {
return new NextResponse("Not found.", { status: 404, headers: NO_STORE });
}
fs.rmSync(resolved.path);
return new NextResponse(null, { status: 204, headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor";
import { isCrossOrigin } from "@/lib/server/workspace";
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
import { createBackup, listBackups, pruneBackups } from "@/lib/server/backup";
import { getSystemConfig } from "@/lib/server/systemConfig";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function forbidden() {
return NextResponse.json(
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
{ status: 403, headers: NO_STORE }
);
}
export async function GET(request: NextRequest) {
let actor: Actor | undefined;
try {
if (isCrossOrigin(request)) return forbidden();
actor = requireActor(request);
requireAdmin(actor);
return NextResponse.json({ backups: listBackups() }, { headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error, actor);
}
}
export async function POST(request: NextRequest) {
let actor: Actor | undefined;
try {
if (isCrossOrigin(request)) return forbidden();
actor = requireActor(request);
requireAdmin(actor);
const entry = await createBackup("manual");
pruneBackups(getSystemConfig().backup.schedule.keepCount);
return NextResponse.json(entry, { headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error, actor);
}
}
+80
View File
@@ -0,0 +1,80 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backup-route-"));
const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR;
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
const collection = await import("./route");
const single = await import("./[file]/route");
const { backupsDir, listBackups } = await import("@/lib/server/backup");
function params(file: string) {
return { params: Promise.resolve({ file }) };
}
describe("backup routes", () => {
beforeEach(() => {
fs.rmSync(backupsDir(), { recursive: true, force: true });
});
afterAll(() => {
if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR;
else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
});
it("POST creates an archive and GET lists it", async () => {
const created = await collection.POST(
new Request("http://localhost/api/system/backup", { method: "POST" }) as never
);
expect(created.status).toBe(200);
const entry = await created.json();
expect(entry.name).toMatch(/^backup-\d{8}T\d{6}-manual\.tar\.gz$/);
const list = await collection.GET(
new Request("http://localhost/api/system/backup") as never
);
const body = await list.json();
expect(body.backups.map((b: { name: string }) => b.name)).toContain(entry.name);
});
it("GET [file] streams the archive; DELETE removes it", async () => {
fs.mkdirSync(backupsDir(), { recursive: true });
const name = "backup-20260811T000000-manual.tar.gz";
fs.writeFileSync(path.join(backupsDir(), name), "gzip-bytes");
const download = await single.GET(
new Request(`http://localhost/api/system/backup/${name}`) as never,
params(name)
);
expect(download.status).toBe(200);
expect(download.headers.get("Content-Type")).toBe("application/gzip");
expect(download.headers.get("Content-Disposition")).toContain(name);
expect(await download.text()).toBe("gzip-bytes");
const removed = await single.DELETE(
new Request(`http://localhost/api/system/backup/${name}`, { method: "DELETE" }) as never,
params(name)
);
expect(removed.status).toBe(204);
expect(listBackups()).toHaveLength(0);
});
it("rejects invalid names with 400 and missing files with 404", async () => {
const traversal = await single.GET(
new Request("http://localhost/api/system/backup/..%2Fsecret") as never,
params("..%2Fsecret")
);
expect(traversal.status).toBe(400);
const missing = await single.GET(
new Request("http://localhost/api/system/backup/backup-20260811T000000-auto.tar.gz") as never,
params("backup-20260811T000000-auto.tar.gz")
);
expect(missing.status).toBe(404);
});
});