Files
pinkudex/app/api/backup/library-import/route.ts
T
2026-05-26 22:46:00 +02:00

258 lines
9.0 KiB
TypeScript

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<yauzl.ZipFile> {
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<Readable> {
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<void> {
const zf = await openZip(zipPath);
try {
await new Promise<void>((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<string[]> {
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<string, unknown[]> };
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-<ts>
// 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(() => {});
}
}