import { NextResponse } from "next/server"; import { unzipSync, strFromU8 } from "fflate"; import { z } from "zod"; import { UserRole } from "@prisma/client"; import { db } from "@/lib/db"; import { requireAuth, isAdmin } from "@/lib/auth"; export const dynamic = "force-dynamic"; // A malformed/crafted backup must not be able to inject arbitrary auth rows. // users is the security boundary, so it's validated strictly (known fields only, // role constrained to the enum). Other tables are app-owned data — we only check // they're arrays; Prisma rejects any unknown columns on insert. class BadBackup extends Error {} const userSchema = z .object({ id: z.string(), email: z.string(), username: z.string().nullable().optional(), name: z.string(), passwordHash: z.string(), role: z.nativeEnum(UserRole).optional(), active: z.boolean().optional(), mustChangePassword: z.boolean().optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), }) .strict(); export async function POST(req: Request) { let session; try { session = await requireAuth(); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (!isAdmin(session.user.role)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } try { const form = await req.formData(); const file = form.get("file") as File | null; if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); let files: Record; try { files = unzipSync(new Uint8Array(await file.arrayBuffer())); } catch { throw new BadBackup("not a valid zip"); } const parse = (name: string): unknown => { if (!files[name]) throw new BadBackup(`missing ${name}`); try { return JSON.parse(strFromU8(files[name])); } catch { throw new BadBackup(`invalid JSON in ${name}`); } }; const asArray = (name: string): unknown[] => { const v = parse(name); if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`); return v; }; const manifest = parse("manifest.json") as { version?: number; exportedAt?: string }; if (manifest?.version !== 1) { return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 }); } // Older backups won't have newer tables — tolerate their absence. const locations = files["locations.json"] ? asArray("locations.json") : []; const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : []; const users = z.array(userSchema).parse(asArray("users.json")); const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : []; const plantGroups = asArray("plantGroups.json"); const plants = asArray("plants.json"); const plantLogs = asArray("plantLogs.json"); const tasks = asArray("tasks.json"); const taskCompletions = asArray("taskCompletions.json"); const labels = files["labels.json"] ? asArray("labels.json") : []; const items = files["items.json"] ? asArray("items.json") : []; const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : []; const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : []; const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : []; const animals = files["animals.json"] ? asArray("animals.json") : []; const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : []; const auditEvents = asArray("auditEvents.json"); // Restore inside a transaction — wipe then reload in FK order. await db.$transaction(async (tx) => { await tx.taskCompletion.deleteMany(); await tx.plantLog.deleteMany(); await tx.itemLog.deleteMany(); await tx.itemAttachment.deleteMany(); await tx.labelOnItem.deleteMany(); await tx.animalLog.deleteMany(); await tx.task.deleteMany(); await tx.item.deleteMany(); await tx.label.deleteMany(); await tx.animal.deleteMany(); await tx.plant.deleteMany(); await tx.plantGroup.deleteMany(); await tx.plantType.deleteMany(); await tx.location.deleteMany(); await tx.apiToken.deleteMany(); await tx.auditEvent.deleteMany(); await tx.user.deleteMany(); if (users.length) await tx.user.createMany({ data: users }); if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never }); // Location self-references parentId; a single createMany is one statement, // so the FK is validated only after every row exists — order-independent. if (locations.length) await tx.location.createMany({ data: locations as never }); if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never }); if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never }); if (plants.length) await tx.plant.createMany({ data: plants as never }); if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never }); if (labels.length) await tx.label.createMany({ data: labels as never }); // Item self-references parentItemId; one createMany statement validates the // FK only after all rows exist — order-independent. if (items.length) await tx.item.createMany({ data: items as never }); if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never }); if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never }); if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never }); if (animals.length) await tx.animal.createMany({ data: animals as never }); if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never }); if (tasks.length) await tx.task.createMany({ data: tasks as never }); if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never }); if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never }); }); await db.auditEvent.create({ data: { action: "admin.restore", actorId: session.user.id, payload: { exportedAt: manifest.exportedAt, restoredAt: new Date().toISOString() }, }, }); return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt }); } catch (err) { if (err instanceof BadBackup || err instanceof z.ZodError) { return NextResponse.json( { error: "The backup file is invalid or corrupted." }, { status: 400 } ); } console.error("restore failed:", err); return NextResponse.json({ error: "Restore failed." }, { status: 500 }); } }