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 yazl from "yazl"; import { rawDb } from "@/lib/db/client"; import { assertLocalRequest } from "@/lib/api/localOnly"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 3600; const ROOT = process.cwd(); const SOURCES: Array<{ absDir: string; zipPrefix: string; skipRel?: (rel: string) => boolean }> = [ { absDir: path.join(ROOT, "library"), zipPrefix: "library", skipRel: (rel) => rel === ".superseded" || rel.startsWith(".superseded/"), }, { absDir: path.join(ROOT, "data", "thumbs"), zipPrefix: "data/thumbs" }, { absDir: path.join(ROOT, "data", "portraits"), zipPrefix: "data/portraits" }, { absDir: path.join(ROOT, "data", "category-covers"), zipPrefix: "data/category-covers" }, { absDir: path.join(ROOT, "data", "collection-covers"), zipPrefix: "data/collection-covers" }, ]; const DB_TABLES = [ "images", "studios", "labels", "series", "actresses", "genres", "tag_categories", "tags", "collections", "image_actresses", "image_genres", "image_tags", "collection_images", "actress_categories", "actress_categories_map", "app_settings", ]; type Entry = { abs: string; rel: string; size: number; mtime: Date }; async function walk(absDir: string, zipPrefix: string, skipRel?: (rel: string) => boolean): Promise { const out: Entry[] = []; const stack: Array<{ abs: string; rel: string }> = [{ abs: absDir, rel: "" }]; while (stack.length) { const { abs, rel } = stack.pop()!; let dirents: fs.Dirent[]; try { dirents = await fsp.readdir(abs, { withFileTypes: true }); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") continue; throw e; } for (const d of dirents) { const childRel = rel ? `${rel}/${d.name}` : d.name; if (skipRel?.(childRel)) continue; const childAbs = path.join(abs, d.name); if (d.isDirectory()) { stack.push({ abs: childAbs, rel: childRel }); } else if (d.isFile()) { const st = await fsp.stat(childAbs); out.push({ abs: childAbs, rel: `${zipPrefix}/${childRel}`, size: st.size, mtime: st.mtime }); } } } return out; } function buildDatabaseJson(): Buffer { const data: Record = {}; for (const t of DB_TABLES) { try { data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all(); } catch { data[t] = []; } } const payload = { app: "Pinkudex", version: 1, exportedAt: new Date().toISOString(), tables: data, }; return Buffer.from(JSON.stringify(payload, null, 2), "utf8"); } export async function GET(req: NextRequest) { const blocked = assertLocalRequest(req); if (blocked) return blocked; let entries: Entry[] = []; for (const src of SOURCES) { entries = entries.concat(await walk(src.absDir, src.zipPrefix, src.skipRel)); } // Materialise the zip to a temp file first. Streaming yazl's outputStream // straight through NextResponse hung mid-download (turbopack streaming / // adapter chunking weirdness). A temp file guarantees: exact Content-Length // from fs.stat, full backpressure handling by Node's createReadStream, and // resumability if the browser retries. Costs one extra pass over disk — // acceptable for an explicit user-triggered backup. const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-export-")); const tmpZip = path.join(tmpDir, "library.zip"); const zip = new yazl.ZipFile(); zip.addBuffer(buildDatabaseJson(), "database.json", { compress: false }); for (const e of entries) { zip.addReadStream(fs.createReadStream(e.abs), e.rel, { compress: false, size: e.size, mtime: e.mtime, }); } zip.end(); await pipeline( zip.outputStream as unknown as Readable, fs.createWriteStream(tmpZip), ); const { size } = await fsp.stat(tmpZip); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileStream = fs.createReadStream(tmpZip); // Best-effort cleanup once the stream is fully consumed (success or abort). fileStream.on("close", () => { fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); }); const webStream = Readable.toWeb(fileStream) as unknown as ReadableStream; return new NextResponse(webStream, { headers: { "Content-Type": "application/zip", "Content-Length": String(size), "Content-Disposition": `attachment; filename="pinkudex-library-${stamp}.zip"`, "Cache-Control": "no-store", }, }); }