import { NextRequest, NextResponse } from "next/server"; import path from "node:path"; import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import yauzl from "yauzl"; import { assertLocalRequest } from "@/lib/api/localOnly"; import { importDatabaseTables, restoreDatabaseSnapshot } from "@/lib/backup/importDb"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 3600; const ROOT = process.cwd(); // Folders the export route ships and that we expect to restore. zipPrefix is // the path inside the archive; absDir is where it lives on disk. const TARGETS: Array<{ zipPrefix: string; absDir: string }> = [ { zipPrefix: "library", absDir: path.join(ROOT, "library") }, { zipPrefix: "data/thumbs", absDir: path.join(ROOT, "data", "thumbs") }, { zipPrefix: "data/portraits", absDir: path.join(ROOT, "data", "portraits") }, { zipPrefix: "data/category-covers", absDir: path.join(ROOT, "data", "category-covers") }, { zipPrefix: "data/collection-covers", absDir: path.join(ROOT, "data", "collection-covers") }, ]; function openZip(zipPath: string): Promise { return new Promise((resolve, reject) => { yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => { if (err || !zf) return reject(err ?? new Error("Failed to open zip")); resolve(zf); }); }); } function readEntryStream(zf: yauzl.ZipFile, entry: yauzl.Entry): Promise { return new Promise((resolve, reject) => { zf.openReadStream(entry, (err, stream) => { if (err || !stream) return reject(err ?? new Error("Failed to open entry stream")); resolve(stream); }); }); } // Reject zip-slip / path-traversal entries: the resolved destination must // stay strictly inside the staging root. Without this, a crafted entry // named "../../etc/passwd" would write outside the staging folder. function safeJoin(root: string, rel: string): string | null { const resolved = path.resolve(root, rel); const rel2 = path.relative(root, resolved); if (rel2.startsWith("..") || path.isAbsolute(rel2)) return null; return resolved; } async function extractAll(zipPath: string, stagingRoot: string): Promise { const zf = await openZip(zipPath); try { await new Promise((resolve, reject) => { zf.on("error", reject); zf.on("end", resolve); zf.on("entry", async (entry: yauzl.Entry) => { try { const isDir = /\/$/.test(entry.fileName); const dest = safeJoin(stagingRoot, entry.fileName); if (!dest) { // Skip suspicious entries silently and continue. zf.readEntry(); return; } if (isDir) { await fsp.mkdir(dest, { recursive: true }); zf.readEntry(); return; } await fsp.mkdir(path.dirname(dest), { recursive: true }); const rs = await readEntryStream(zf, entry); await pipeline(rs, fs.createWriteStream(dest)); zf.readEntry(); } catch (e) { reject(e); } }); zf.readEntry(); }); } finally { zf.close(); } } async function rollbackMediaSwap( renamedBackups: Array<{ from: string; to: string }>, movedTargets: string[], ts: string, ): Promise { const errors: string[] = []; for (const tgt of [...TARGETS].reverse()) { const backup = renamedBackups.find((r) => r.from === tgt.absDir); const wasMoved = movedTargets.includes(tgt.zipPrefix); if (!backup && !wasMoved) continue; if (wasMoved) { try { await fsp.access(tgt.absDir); await fsp.rename(tgt.absDir, `${tgt.absDir}.failed-restore-${ts}`); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") { errors.push(`Could not move failed ${tgt.zipPrefix}: ${(e as Error).message}`); } } } if (backup) { try { await fsp.rename(backup.to, backup.from); } catch (e) { errors.push(`Could not restore backup ${backup.to}: ${(e as Error).message}`); } } } return errors; } export async function POST(req: NextRequest) { const blocked = assertLocalRequest(req); if (blocked) return blocked; if (!req.body) { return NextResponse.json({ error: "Missing request body" }, { status: 400 }); } const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-import-")); const tmpZip = path.join(tmpDir, "upload.zip"); const staging = path.join(tmpDir, "staging"); try { // 1) Stream upload to disk. Buffering a multi-GB upload in memory is a // non-starter; piping the request body straight to a file is constant-RAM. await pipeline( Readable.fromWeb(req.body as unknown as import("node:stream/web").ReadableStream), fs.createWriteStream(tmpZip), ); // 2) Extract everything to staging. If anything throws here we abort // before touching live state. await fsp.mkdir(staging, { recursive: true }); await extractAll(tmpZip, staging); // 3) Read database.json and validate. const dbJsonPath = path.join(staging, "database.json"); let dbJsonRaw: string; try { dbJsonRaw = await fsp.readFile(dbJsonPath, "utf8"); } catch { return NextResponse.json( { error: "Archive is missing database.json — not a Pinkudex library export." }, { status: 400 }, ); } let parsed: { tables?: Record }; try { parsed = JSON.parse(dbJsonRaw); } catch { return NextResponse.json({ error: "database.json is not valid JSON." }, { status: 400 }); } const tables = parsed.tables; if (!tables || typeof tables !== "object") { return NextResponse.json({ error: "database.json missing 'tables' object." }, { status: 400 }); } if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) { return NextResponse.json( { error: "database.json is missing core tables." }, { status: 400 }, ); } // 4) Import DB. On failure the helper restores a pre-import .bak snapshot // and we abort BEFORE swapping any folders so live media is untouched. const dbResult = await importDatabaseTables(tables); if (!dbResult.ok) { return NextResponse.json( { error: `Database import failed: ${dbResult.error}`, snapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, hint: "Media folders were not modified. The DB was rolled back from the pre-import snapshot.", }, { status: 500 }, ); } // 5) Swap media folders. Existing folders renamed to *.pre-restore- // for manual rollback. New folders moved out of staging into place. const ts = new Date().toISOString().replace(/[:.]/g, "-"); const renamedBackups: Array<{ from: string; to: string }> = []; const movedTargets: string[] = []; try { for (const tgt of TARGETS) { const stagedSrc = path.join(staging, tgt.zipPrefix); let stagedExists = false; try { const st = await fsp.stat(stagedSrc); stagedExists = st.isDirectory(); } catch {} if (!stagedExists) continue; // Rename existing folder if present. try { await fsp.access(tgt.absDir); const backupPath = `${tgt.absDir}.pre-restore-${ts}`; await fsp.rename(tgt.absDir, backupPath); renamedBackups.push({ from: tgt.absDir, to: backupPath }); } catch { // Didn't exist — fine. } await fsp.mkdir(path.dirname(tgt.absDir), { recursive: true }); await fsp.rename(stagedSrc, tgt.absDir); movedTargets.push(tgt.zipPrefix); } } catch (e) { const rollbackErrors = await rollbackMediaSwap(renamedBackups, movedTargets, ts); if (dbResult.snapshotPath) { try { await restoreDatabaseSnapshot(dbResult.snapshotPath); } catch (restoreErr) { rollbackErrors.push(`Could not restore DB snapshot: ${(restoreErr as Error).message}`); } } return NextResponse.json( { error: `Library restore failed during media swap: ${(e as Error).message}`, dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)), rollbackErrors, }, { status: 500 }, ); } return NextResponse.json({ ok: true, counts: dbResult.counts, errors: dbResult.errors, dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, mediaRestored: movedTargets, mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)), }); } catch (e) { return NextResponse.json( { error: `Library restore failed: ${(e as Error).message}` }, { status: 500 }, ); } finally { fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } }