Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299import { spawn } from "node:child_process";import crypto from "node:crypto";import fs from "node:fs";import fsp from "node:fs/promises";import path from "node:path";import { Readable } from "node:stream";
type ExportResult = { archivePath: string; filename: string; tmpDir: string };
type ExportManifest = { createdAt: string; version: string; files: Array<{ name: string; md5: string; size: number }>;};
function toError(err: unknown): Error { return err instanceof Error ? err : new Error(String(err));}
function timingSafeEqualString(a: string, b: string): boolean { const aBuf = Buffer.from(a); const bBuf = Buffer.from(b); if (aBuf.length !== bBuf.length) { crypto.timingSafeEqual(aBuf, aBuf); return false; } return crypto.timingSafeEqual(aBuf, bBuf);}
export function isAuthorizedExportRequest(opts: { authorizationHeader?: string; sharedSecret: string;}) { const { authorizationHeader, sharedSecret } = opts; if (!sharedSecret) return false; if (!authorizationHeader) return false; const match = authorizationHeader.match(/^Bearer\s+(.+)$/i); if (!match) return false; return timingSafeEqualString(match[1]!, sharedSecret);}
/** Run pg_dump and write the output to a file. */async function pgDumpBackup({ databaseUrl, destPath, excludeTables,}: { databaseUrl: string; destPath: string; excludeTables?: string[];}): Promise<void> { const args = ["--format=custom", "--file", destPath]; for (const table of excludeTables ?? []) { args.push("--exclude-table", table); } args.push(databaseUrl);
return new Promise((resolve, reject) => { const proc = spawn("pg_dump", args, { stdio: ["ignore", "ignore", "pipe"], }); const stderr: string[] = []; proc.stderr.on("data", (d) => stderr.push(d.toString())); proc.on("error", reject); proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.join("").trim() || "no stderr"}`)), ); });}
function createTgz(cwd: string, outputFile: string, files: string[]): Promise<void> { return new Promise((resolve, reject) => { const proc = spawn("tar", ["-czf", outputFile, "-C", cwd, ...files], { stdio: ["ignore", "ignore", "pipe"], }); const stderr: string[] = []; proc.stderr.on("data", (d) => stderr.push(d.toString())); proc.on("error", reject); proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${stderr.join("").trim() || "no stderr"}`)), ); });}
export function createTgzReadStream( cwd: string, files: string[], callbacks?: { onClose?: () => void; onError?: (err: Error) => void },): ReadableStream<Uint8Array> { const proc = spawn("tar", ["-czf", "-", "-C", cwd, ...files], { stdio: ["ignore", "pipe", "pipe"], }); const stderr: string[] = []; if (proc.stderr) { proc.stderr.on("data", (d: Buffer) => stderr.push(d.toString())); } proc.on("error", (err: Error) => callbacks?.onError?.(err)); proc.on("close", (code: number | null) => { if (code === 0) { callbacks?.onClose?.(); } else { callbacks?.onError?.( new Error(`tar exited ${code}: ${stderr.join("").trim() || "no stderr"}`), ); } }); return Readable.toWeb(proc.stdout!) as unknown as ReadableStream<Uint8Array>;}
function computeFileMd5(filePath: string): string { const content = fs.readFileSync(filePath); return crypto.createHash("md5").update(content).digest("hex");}
async function getFileStats(filePath: string): Promise<{ md5: string; size: number }> { const { size } = await fsp.stat(filePath); return { md5: computeFileMd5(filePath), size };}
type PrepareResult = { tmpDir: string; filename: string; files: string[];};
export async function prepareSanitizedExportFiles(opts: { databaseUrl: string; exportDir: string;}): Promise<PrepareResult> { const { databaseUrl, exportDir } = opts;
await cleanupStaleExports(exportDir);
const now = new Date(); const stamp = now.toISOString().replace(/[:.]/g, "-"); const runId = crypto.randomUUID(); const tmpDir = path.join(exportDir, `bookhive-export-${stamp}-${runId}`);
try { await fsp.mkdir(tmpDir, { recursive: true }); } catch (err) { throw new Error( `Failed to create export temporary directory at ${tmpDir}: ${toError(err).message}`, ); }
const filename = `bookhive-export-${stamp}-${runId.slice(0, 8)}.tgz`;
try { const dbOut = path.join(tmpDir, "db.dump"); try { await pgDumpBackup({ databaseUrl, destPath: dbOut, excludeTables: ["kv_auth_sessions", "kv_auth_state"], }); } catch (err) { throw new Error(`Failed to backup database: ${toError(err).message}`); }
const includedFiles: ExportManifest["files"] = [];
const dbStats = await getFileStats(dbOut); includedFiles.push({ name: "db.dump", md5: dbStats.md5, size: dbStats.size });
const manifest: ExportManifest = { createdAt: now.toISOString(), version: "2.0", files: includedFiles, };
const manifestPath = path.join(tmpDir, "manifest.json"); try { await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); } catch (err) { throw new Error(`Failed to write manifest file: ${toError(err).message}`); }
const manifestStats = await getFileStats(manifestPath); includedFiles.push({ name: "manifest.json", md5: manifestStats.md5, size: manifestStats.size });
return { tmpDir, filename, files: includedFiles.map((f) => f.name) }; } catch (err) { await cleanupExportPaths({ tmpDir }); throw err; }}
export async function createSanitizedExportArchive(opts: { databaseUrl: string; exportDir: string;}): Promise<ExportResult> { const { databaseUrl, exportDir } = opts;
await cleanupStaleExports(exportDir);
const now = new Date(); const stamp = now.toISOString().replace(/[:.]/g, "-"); const runId = crypto.randomUUID(); const tmpDir = path.join(exportDir, `bookhive-export-${stamp}-${runId}`);
try { await fsp.mkdir(tmpDir, { recursive: true }); } catch (err) { throw new Error( `Failed to create export temporary directory at ${tmpDir}: ${toError(err).message}`, ); }
const filename = `bookhive-export-${stamp}-${runId.slice(0, 8)}.tgz`; const archivePath = path.join(exportDir, filename);
try { const dbOut = path.join(tmpDir, "db.dump"); try { await pgDumpBackup({ databaseUrl, destPath: dbOut, excludeTables: ["kv_auth_sessions", "kv_auth_state"], }); } catch (err) { throw new Error(`Failed to backup database: ${toError(err).message}`); }
const includedFiles: ExportManifest["files"] = [];
const dbStats = await getFileStats(dbOut); includedFiles.push({ name: "db.dump", md5: dbStats.md5, size: dbStats.size });
const manifest: ExportManifest = { createdAt: now.toISOString(), version: "2.0", files: includedFiles, };
const manifestPath = path.join(tmpDir, "manifest.json"); try { await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); } catch (err) { throw new Error(`Failed to write manifest file: ${toError(err).message}`); }
const manifestStats = await getFileStats(manifestPath); includedFiles.push({ name: "manifest.json", md5: manifestStats.md5, size: manifestStats.size });
try { await createTgz( tmpDir, archivePath, includedFiles.map((f) => f.name), ); } catch (err) { throw new Error(`Failed to create tar archive: ${toError(err).message}`); }
return { archivePath, filename, tmpDir }; } catch (err) { await cleanupExportPaths({ archivePath, tmpDir }); throw err; }}
async function cleanupStaleExports(exportDir: string): Promise<void> { let entries: string[]; try { entries = await fsp.readdir(exportDir); } catch { return; // exportDir doesn't exist yet — nothing to clean } const stale = entries.filter((e) => e.startsWith("bookhive-export-")); await Promise.allSettled( stale.map((e) => fsp.rm(path.join(exportDir, e), { recursive: true, force: true })), );}
export async function cleanupExportPaths(paths: { archivePath?: string; tmpDir?: string;}): Promise<void> { const promises: Promise<unknown>[] = []; if (paths.archivePath) promises.push(fsp.rm(paths.archivePath, { force: true })); if (paths.tmpDir) promises.push(fsp.rm(paths.tmpDir, { recursive: true, force: true })); await Promise.allSettled(promises);}
export function createExportReadStream( filePath: string, callbacks?: { onClose?: () => void; onError?: (err: Error) => void },): ReadableStream<Uint8Array> { const stream = fs.createReadStream(filePath, { highWaterMark: 64 * 1024 }); stream.on("close", () => callbacks?.onClose?.()); stream.on("error", (err) => callbacks?.onError?.(err)); return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;}