Initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { assertLocalRequest } from "@/lib/api/localOnly";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const 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",
|
||||
];
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const blocked = assertLocalRequest(req);
|
||||
if (blocked) return blocked;
|
||||
const data: Record<string, unknown[]> = {};
|
||||
for (const t of 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,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return new NextResponse(json, {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="pinkudex-backup-${stamp}.json"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import path from "node:path";
|
||||
import { assertLocalRequest } from "@/lib/api/localOnly";
|
||||
import { importDatabaseTables } from "@/lib/backup/importDb";
|
||||
|
||||
// Strip absolute-path noise — only the basename is useful to the client
|
||||
// and absolute paths leak filesystem layout to anything that pings the
|
||||
// API on the local network.
|
||||
const baseOnly = (p: string | null | undefined): string | null =>
|
||||
p ? path.basename(p) : null;
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const blocked = assertLocalRequest(req);
|
||||
if (blocked) return blocked;
|
||||
|
||||
let payload: { tables?: Record<string, unknown[]>; version?: number; app?: string };
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tables = payload.tables;
|
||||
if (!tables || typeof tables !== "object") {
|
||||
return NextResponse.json({ error: "Missing 'tables' object" }, { status: 400 });
|
||||
}
|
||||
if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) {
|
||||
return NextResponse.json({ error: "Backup payload is missing core tables." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await importDatabaseTables(tables);
|
||||
const snapshotName = baseOnly(result.snapshotPath);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error,
|
||||
snapshotName,
|
||||
hint: snapshotName
|
||||
? `Live DB rolled back. A pre-import snapshot was saved as ${snapshotName}.`
|
||||
: undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
counts: result.counts,
|
||||
errors: result.errors,
|
||||
snapshotName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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<Entry[]> {
|
||||
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<string, unknown[]> = {};
|
||||
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<Uint8Array>;
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user