spb/wp-spboucher-ai
Public
1import { promises as fs } from "node:fs";2import { join } from "node:path";3import { sql } from "@/db";4import { requireUser, jsonError } from "@/lib/auth/guard";5import { clientIp } from "@/lib/auth/session";6import { audit } from "@/lib/audit";7import { enqueue } from "@/lib/jobs/queue";8import { parseVersionLabel } from "@/lib/papers/identifiers";9import { ensureStorage, pdfIntegrity, sha256File, storeFile } from "@/lib/pdf/process";10import { STORAGE_DIR } from "@/lib/env";11import { headers } from "next/headers";12export const dynamic = "force-dynamic";13const MAX = 150 * 1024 * 1024;14export async function POST(req: Request) {15 try {16 const u = await requireUser(req); const fd = await req.formData();17 const file = fd.get("file"); if (!(file instanceof File)) throw new Error("file missing"); if (file.size > MAX) throw new Error("file too large (max 150 MB)"); if (file.type && file.type !== "application/pdf") throw new Error("PDF only");18 const paperId = String(fd.get("paper_id") ?? ""); const [p] = await sql`SELECT id, slug FROM papers WHERE id = ${paperId}`; if (!p) throw new Error("paper not found");19 await ensureStorage(); const tmp = join(STORAGE_DIR, "tmp", `${crypto.randomUUID()}.pdf`); await fs.writeFile(tmp, Buffer.from(await file.arrayBuffer()), { mode: 0o600 });20 const head = await fs.readFile(tmp, { encoding: "latin1", flag: "r" }).then((s) => s.slice(0, 5)); if (!head.startsWith("%PDF")) { await fs.unlink(tmp); throw new Error("not a PDF file"); }21 const integ = await pdfIntegrity(tmp); if (!integ.ok) { await fs.unlink(tmp); throw new Error(`PDF integrity check failed: ${integ.reason}`); }22 const sha = await sha256File(tmp); let [f] = await sql`SELECT id FROM files WHERE sha256 = ${sha}`;23 if (!f) { const key = await storeFile(tmp, sha); [f] = await sql`INSERT INTO files (kind, storage_key, original_name, mime, bytes, sha256, pages, integrity_ok) VALUES ('manuscript', ${key}, ${file.name.slice(0, 200)}, 'application/pdf', ${file.size}, ${sha}, ${integ.info?.pages ?? null}, true) RETURNING id`; }24 await fs.unlink(tmp).catch(() => {});25 const [dupv] = await sql`SELECT label FROM paper_versions WHERE paper_id = ${p.id} AND sha256 = ${sha}`; if (dupv) throw new Error(`This exact file is already version ${dupv.label}`);26 const [{ next }] = await sql`SELECT coalesce(max(version_no), 0) + 1 AS next FROM paper_versions WHERE paper_id = ${p.id}`;27 const label = parseVersionLabel(String(fd.get("label") ?? "")) ?? { versionNo: next, label: `v${next}` };28 const dateRaw = String(fd.get("released_on") ?? "").trim(); const precision = /^\d{4}$/.test(dateRaw) ? "year" : /^\d{4}-\d{2}$/.test(dateRaw) ? "month" : "day"; const date = !dateRaw ? null : precision === "year" ? `${dateRaw}-01-01` : precision === "month" ? `${dateRaw}-01` : dateRaw;29 const makeCurrent = fd.get("make_current") === "on";30 const [v] = await sql`INSERT INTO paper_versions (paper_id, version_no, label, released_on, released_precision, description, file_id, pages, sha256, bytes, is_current) VALUES (${p.id}, ${next}, ${label.label}, ${date}, ${precision}, ${String(fd.get("description") ?? "") || null}, ${f.id}, ${integ.info?.pages ?? null}, ${sha}, ${file.size}, ${makeCurrent}) RETURNING id`;31 if (makeCurrent) { await sql`UPDATE paper_versions SET is_current = false WHERE paper_id = ${p.id} AND id <> ${v.id}`; await sql`UPDATE papers SET current_version_id = ${v.id}, last_revised_on = coalesce(${date}, last_revised_on), last_revised_precision = ${date ? precision : sql`last_revised_precision`} WHERE id = ${p.id}`; }32 await enqueue("process_pdf", { fileId: f.id, paperId: makeCurrent ? p.id : undefined, versionId: v.id });33 await audit({ id: u.id, label: u.email, ip: clientIp(await headers()) }, "version.upload", "paper_version", v.id, null, { paper: p.slug, label: label.label, sha256: sha, bytes: file.size });34 return Response.json({ ok: true, versionId: v.id, label: label.label, sha256: sha });35 } catch (e) { return jsonError(e); }36}37