feat(webui): backup run/history/download/delete API routes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user