diff --git a/src/app/api/system/backup/[file]/route.ts b/src/app/api/system/backup/[file]/route.ts new file mode 100644 index 0000000..9991845 --- /dev/null +++ b/src/app/api/system/backup/[file]/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/system/backup/route.ts b/src/app/api/system/backup/route.ts new file mode 100644 index 0000000..ec95af5 --- /dev/null +++ b/src/app/api/system/backup/route.ts @@ -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); + } +} diff --git a/src/app/api/system/backup/routes.test.ts b/src/app/api/system/backup/routes.test.ts new file mode 100644 index 0000000..1f645a9 --- /dev/null +++ b/src/app/api/system/backup/routes.test.ts @@ -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); + }); +});