Make the container actually deployable, publish to GHCR

The Docker setup built but never came up. Three separate blockers, each
found by running the full compose stack against a real Postgres:

- The runner stage hand-copied prisma/@prisma/dotenv, which misses the
  CLI's transitive deps (valibot) and crash-looped the entrypoint. A
  dedicated migrator stage now installs the CLI at the exact lockfile
  version with its real dependency tree.
- Docker sets HOSTNAME to the container id and Next's standalone server
  binds to it verbatim, so the app was unreachable on localhost inside
  the container and the healthcheck could never pass. Pinned to 0.0.0.0.
- openssl was missing from the build stages but present at runtime, so
  Prisma detected a different platform and tried to download a matching
  query engine at boot, as an unprivileged user, into a root-owned
  directory. Aligned openssl across every stage that touches Prisma.

Verified from a clean volume: migrations apply, both containers report
healthy, /api/health returns 200, and the app serves from the image.

Also:
- Publish multi-arch images to GHCR on push to main and on v* tags, with
  an SBOM and a signed provenance attestation.
- Default compose to the published image; add a build override for
  working on the app itself. Host port and image are configurable.
- Drop two unused dependencies: canvas (19MB of native node-canvas; the
  pipeline uses @napi-rs/canvas) and multer (routes use formData()).
- Add a root .gitignore. There wasn't one, so a real .env was one
  `git add .` away from being committed. Untrack .DS_Store.
- Rewrite the README around what the tool does and how to run it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-09-01 23:54:57 -05:00
1 parent 4130ce2e09
commit 7e7e4d9b1c
36 files changed
+2260 -1270

No files matched your search

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { readFile } from "fs/promises";
import { sourcePdfPath, resultPagePath, type ResultKind } from "@/lib/storage";
/**
* Authenticated file server for document assets. Streams stored files from
* STORAGE_DIR only after verifying the session owns the document, replacing the
* previous public/ static URLs.
*
* ?type=source → the original uploaded PDF
* ?type=original&page=N → clean rendered page image
* ?type=pixel&page=N → sanitized page image
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const type = req.nextUrl.searchParams.get("type") ?? "original";
const pageParam = req.nextUrl.searchParams.get("page");
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
if (!doc) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user || doc.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
if (type === "source") {
const data = await readFile(sourcePdfPath(doc.filePath));
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": "application/pdf",
"Cache-Control": "private, no-store",
},
});
}
if (type !== "original" && type !== "pixel") {
return NextResponse.json({ error: "Invalid asset type" }, { status: 400 });
}
const page = Number(pageParam);
if (!Number.isInteger(page) || page < 1) {
return NextResponse.json({ error: "Invalid page" }, { status: 400 });
}
const data = await readFile(
resultPagePath(user.id, id, type as ResultKind, page)
);
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "private, no-store",
},
});
} catch {
return NextResponse.json({ error: "Asset not found" }, { status: 404 });
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { removeDocumentFiles } from "@/lib/storage";
export async function DELETE(
_req: NextRequest,
@@ -28,6 +29,7 @@ export async function DELETE(
}
await prisma.pdfDocument.delete({ where: { id } });
await removeDocumentFiles(user.id, id, doc.filePath);
return NextResponse.json({ success: true });
}
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma";
import { readdir, readFile } from "fs/promises";
import path from "path";
import { PDFDocument } from "pdf-lib";
import { resultsDir } from "@/lib/storage";
type ExportType = "original" | "pixel";
@@ -39,16 +40,9 @@ export async function GET(
}
try {
const resultsDir = path.join(
process.cwd(),
"public",
"results",
user.id,
id,
type
);
const dir = resultsDir(user.id, id, type);
const files = (await readdir(resultsDir))
const files = (await readdir(dir))
.filter((fileName) => /^page_\d+\.png$/.test(fileName))
.sort((a, b) => parsePageNumber(a) - parsePageNumber(b));
@@ -62,7 +56,7 @@ export async function GET(
const pdfDoc = await PDFDocument.create();
for (const fileName of files) {
const imagePath = path.join(resultsDir, fileName);
const imagePath = path.join(dir, fileName);
const imageBytes = await readFile(imagePath);
const pngImage = await pdfDoc.embedPng(imageBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
+143 -170
View File
@@ -2,14 +2,43 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { readFile, writeFile, mkdir } from "fs/promises";
import path from "path";
import { readFile } from "fs/promises";
import {
loadPdf,
renderPage,
sanitizePage,
ocrPage,
createOcrWorker,
type OcrWorker,
} from "@/lib/pipeline";
import { sourcePdfPath } from "@/lib/storage";
import { rateLimit } from "@/lib/rateLimit";
import { safeErrorMessage } from "@/lib/safeError";
/**
* Tesseract OCR: runs OCR on EVERY page of the PDF.
* Streams progress back to the client via newline-delimited JSON.
* Only allowed ONCE per document. Must be explicitly triggered.
* Tesseract OCR over every page of a document.
*
* - OCR runs on the SANITIZED image (the same pipeline the Pixel panel uses),
* not the raw render, so hidden low-contrast text is picked up.
* - Word-level boxes + per-page confidence are persisted to the pdfPage table.
* - Runs are idempotent: an existing run's page rows are cleared and overwritten
* rather than rejected.
* - Each page is isolated (one bad page does not abort the run) and retried once
* with backoff. Progress + per-page results/errors stream as NDJSON.
*/
const OCR_RATE_MAX = 3;
const OCR_RATE_WINDOW_MS = 10 * 60 * 1000; // 3 runs / 10 minutes / user
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch {
await new Promise((r) => setTimeout(r, 600));
return fn();
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -26,25 +55,6 @@ export async function POST(
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
if (doc.ocrRan) {
// Return existing results
if (doc.ocrResultDir && doc.ocrPageCount > 0) {
const pages = Array.from({ length: doc.ocrPageCount }, (_, i) => ({
page: i + 1,
path: `${doc.ocrResultDir}/page_${i + 1}.txt`,
}));
return NextResponse.json({
pages,
pageCount: doc.ocrPageCount,
alreadyRan: true,
});
}
return NextResponse.json(
{ error: "OCR has already been run", alreadyRan: true },
{ status: 409 }
);
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
@@ -52,153 +62,116 @@ export async function POST(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath);
const pdfBuffer = await readFile(pdfPath);
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(pdfBuffer),
});
const pdfDoc = await loadingTask.promise;
const numPages = pdfDoc.numPages;
const resultsDir = path.join(
process.cwd(),
"public",
"results",
user.id,
id,
"ocr"
);
await mkdir(resultsDir, { recursive: true });
const publicDir = `/results/${user.id}/${id}/ocr`;
// Stream progress to the client via newline-delimited JSON
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const { createCanvas } = await import("@napi-rs/canvas");
const Tesseract = await import("tesseract.js");
const allPages: { page: number; path: string }[] = [];
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
// Send progress event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "progress",
page: pageNum,
totalPages: numPages,
}) + "\n"
)
);
const page = await pdfDoc.getPage(pageNum);
const scale = 2.0;
const viewport = page.getViewport({ scale });
const width = Math.floor(viewport.width);
const height = Math.floor(viewport.height);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Fill white background for accurate OCR
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas: canvas as any,
viewport,
}).promise;
const imageBuffer = canvas.toBuffer("image/png");
const tempImgPath = path.join(
resultsDir,
`page_${pageNum}_temp.png`
);
await writeFile(tempImgPath, imageBuffer);
const { data } = await Tesseract.recognize(tempImgPath, "eng");
// Save per-page OCR text
const ocrFileName = `page_${pageNum}.txt`;
const ocrPath = path.join(resultsDir, ocrFileName);
await writeFile(ocrPath, data.text, "utf-8");
const publicPath = `${publicDir}/${ocrFileName}`;
allPages.push({ page: pageNum, path: publicPath });
// Send page-complete event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "page_done",
page: pageNum,
totalPages: numPages,
text: data.text,
confidence: data.confidence,
path: publicPath,
}) + "\n"
)
);
}
// Update DB
await prisma.pdfDocument.update({
where: { id },
data: {
ocrRan: true,
ocrResultDir: publicDir,
ocrPageCount: numPages,
pageCount: numPages,
},
});
// Send final complete event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "complete",
pages: allPages,
pageCount: numPages,
}) + "\n"
)
);
controller.close();
} catch (err) {
console.error("OCR streaming error:", err);
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "error",
error: "OCR processing failed",
}) + "\n"
)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
},
});
} catch (e) {
console.error("OCR error:", e);
// Per-user rate limit on OCR runs.
const limit = rateLimit(`ocr:${user.id}`, OCR_RATE_MAX, OCR_RATE_WINDOW_MS);
if (!limit.allowed) {
return NextResponse.json(
{ error: "OCR processing failed" },
{ status: 500 }
{
error: `Rate limit reached. Try again in ${limit.retryAfterSeconds}s.`,
retryAfterSeconds: limit.retryAfterSeconds,
},
{ status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } }
);
}
let pdfDoc: Awaited<ReturnType<typeof loadPdf>>;
let numPages: number;
try {
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
pdfDoc = await loadPdf(pdfBuffer);
numPages = pdfDoc.numPages;
} catch (e) {
console.error("OCR load error:", e);
return NextResponse.json({ error: safeErrorMessage(e) }, { status: 500 });
}
// Idempotent re-run: drop any previously persisted pages for this document.
await prisma.pdfPage.deleteMany({ where: { documentId: id } });
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const emit = (obj: unknown) =>
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
let worker: OcrWorker | null = null;
let succeeded = 0;
try {
worker = await createOcrWorker();
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
emit({ type: "progress", page: pageNum, totalPages: numPages });
try {
const result = await withRetry(async () => {
const { buffer } = await renderPage(pdfDoc, pageNum);
const sanitized = await sanitizePage(buffer);
return ocrPage(worker, sanitized);
});
await prisma.pdfPage.create({
data: {
documentId: id,
pageNumber: pageNum,
confidence: result.confidence,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wordsJson: result.words as any,
},
});
succeeded += 1;
emit({
type: "page_done",
page: pageNum,
totalPages: numPages,
text: result.text,
confidence: result.confidence,
wordCount: result.words.length,
});
} catch (pageErr) {
// One bad page must not kill the whole run.
console.error(`OCR page ${pageNum} failed:`, pageErr);
emit({
type: "page_error",
page: pageNum,
totalPages: numPages,
error: safeErrorMessage(pageErr),
});
}
}
await prisma.pdfDocument.update({
where: { id },
data: {
ocrRan: true,
ocrResultDir: null,
ocrPageCount: succeeded,
pageCount: numPages,
},
});
emit({ type: "complete", pageCount: numPages, succeeded });
} catch (err) {
console.error("OCR streaming error:", err);
emit({ type: "error", error: safeErrorMessage(err) });
} finally {
if (worker) {
try {
await worker.terminate();
} catch {
/* ignore terminate errors */
}
}
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
},
});
}
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { wordsToText, type OcrWord } from "@/lib/pipeline";
/**
* Persisted OCR words for a single page (used by the confidence-heatmap
* overlay so it survives reloads). Returns per-word boxes + confidence, the
* page-level confidence, and text reconstructed from the words.
*
* GET /api/documents/[id]/ocr/words?page=N
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const page = Number(req.nextUrl.searchParams.get("page"));
if (!Number.isInteger(page) || page < 1) {
return NextResponse.json({ error: "Invalid page" }, { status: 400 });
}
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
if (!doc) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user || doc.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const row = await prisma.pdfPage.findUnique({
where: { documentId_pageNumber: { documentId: id, pageNumber: page } },
});
if (!row) {
return NextResponse.json(
{ error: "No OCR data for this page" },
{ status: 404 }
);
}
const words = (row.wordsJson as unknown as OcrWord[]) ?? [];
return NextResponse.json({
page,
confidence: row.confidence,
words,
text: wordsToText(words),
});
}
@@ -2,14 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import sharp from "sharp";
import { readFile, mkdir } from "fs/promises";
import path from "path";
import { readFile, writeFile, mkdir } from "fs/promises";
import { loadPdf, renderPage, sanitizePage } from "@/lib/pipeline";
import { sourcePdfPath, resultsDir, resultPagePath } from "@/lib/storage";
/**
* Pixel-level analysis: renders EVERY page of the PDF as a PNG,
* inverts colors and boosts contrast to reveal hidden white-on-white text.
* Returns an array of per-page result image paths.
* Pixel-level analysis: renders EVERY page of the PDF and runs the shared
* sanitize pipeline (flatten→grayscale→normalize→sharpen) to reveal hidden
* white-on-white / low-contrast text. Returns per-page result image paths that
* are served through the authenticated /asset route.
*/
export async function POST(
req: NextRequest,
@@ -35,84 +36,28 @@ export async function POST(
}
// Always regenerate so stale cached outputs don't persist.
try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath);
const pdfBuffer = await readFile(pdfPath);
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(pdfBuffer),
});
const pdfDoc = await loadingTask.promise;
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
const pdfDoc = await loadPdf(pdfBuffer);
const numPages = pdfDoc.numPages;
const resultsDir = path.join(
process.cwd(),
"public",
"results",
user.id,
id,
"pixel"
);
await mkdir(resultsDir, { recursive: true });
await mkdir(resultsDir(user.id, id, "pixel"), { recursive: true });
const publicDir = `/results/${user.id}/${id}/pixel`;
const pages: { page: number; path: string }[] = [];
const { createCanvas } = await import("@napi-rs/canvas");
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const scale = 2.0;
const viewport = page.getViewport({ scale });
const width = Math.floor(viewport.width);
const height = Math.floor(viewport.height);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Fill with white background first — node-canvas starts transparent
// which causes the image to appear all-black after processing
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas: canvas as any,
viewport,
}).promise;
const imageBuffer = canvas.toBuffer("image/png");
const resultFileName = `page_${pageNum}.png`;
const resultPath = path.join(resultsDir, resultFileName);
// Pixel analysis pipeline:
// 1. flatten alpha to white (ensures no transparent areas)
// 2. grayscale for clarity
// 3. normalize stretches histogram to full 0-255 range,
// revealing subtle near-white hidden text
// 4. sharpen edges to make text crisp
await sharp(imageBuffer)
.flatten({ background: { r: 255, g: 255, b: 255 } })
.grayscale()
.normalize()
.sharpen({ sigma: 3 })
.toFile(resultPath);
const { buffer } = await renderPage(pdfDoc, pageNum);
const sanitized = await sanitizePage(buffer);
await writeFile(resultPagePath(user.id, id, "pixel", pageNum), sanitized);
pages.push({
page: pageNum,
path: `${publicDir}/${resultFileName}`,
path: `/api/documents/${id}/asset?type=pixel&page=${pageNum}`,
});
}
await prisma.pdfDocument.update({
where: { id },
data: {
pixelResultDir: publicDir,
pixelResultDir: `results/${user.id}/${id}/pixel`,
pixelPageCount: numPages,
pageCount: numPages,
},
@@ -121,9 +66,6 @@ export async function POST(
return NextResponse.json({ pages, pageCount: numPages });
} catch (e) {
console.error("Pixel analysis error:", e);
return NextResponse.json(
{ error: "Pixel analysis failed" },
{ status: 500 }
);
return NextResponse.json({ error: "Pixel analysis failed" }, { status: 500 });
}
}
@@ -2,13 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { readFile, mkdir } from "fs/promises";
import path from "path";
import { readFile, writeFile, mkdir } from "fs/promises";
import { loadPdf, renderPage } from "@/lib/pipeline";
import { sourcePdfPath, resultsDir, resultPagePath } from "@/lib/storage";
/**
* Server-side PDF page renderer: renders EVERY page of the PDF as a
* clean PNG image (no processing). Used by the "Original" panel so
* all three panels display consistently via server-rendered images.
* Server-side PDF page renderer: renders EVERY page of the PDF as a clean PNG
* image (no processing) via the shared pipeline. Used by the "Original" panel
* so all three panels display consistently via server-rendered images.
*
* Result images are stored under STORAGE_DIR and served through the
* authenticated /asset route (never as public static files).
*/
export async function POST(
req: NextRequest,
@@ -33,64 +37,24 @@ export async function POST(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const publicDir = `/results/${user.id}/${id}/original`;
const resultsDir = path.join(process.cwd(), "public", publicDir);
// Always regenerate so stale cached renders don't persist.
try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath);
const pdfBuffer = await readFile(pdfPath);
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(pdfBuffer),
});
const pdfDoc = await loadingTask.promise;
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
const pdfDoc = await loadPdf(pdfBuffer);
const numPages = pdfDoc.numPages;
await mkdir(resultsDir, { recursive: true });
await mkdir(resultsDir(user.id, id, "original"), { recursive: true });
const pages: { page: number; path: string }[] = [];
const { createCanvas } = await import("@napi-rs/canvas");
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const scale = 2.0;
const viewport = page.getViewport({ scale });
const width = Math.floor(viewport.width);
const height = Math.floor(viewport.height);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Fill white background — node-canvas defaults to transparent
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas: canvas as any,
viewport,
}).promise;
const resultFileName = `page_${pageNum}.png`;
const resultPath = path.join(resultsDir, resultFileName);
// Write the raw rendered PNG — no sharp processing
const buffer = canvas.toBuffer("image/png");
const { writeFile } = await import("fs/promises");
await writeFile(resultPath, buffer);
const { buffer } = await renderPage(pdfDoc, pageNum);
await writeFile(resultPagePath(user.id, id, "original", pageNum), buffer);
pages.push({
page: pageNum,
path: `${publicDir}/${resultFileName}`,
path: `/api/documents/${id}/asset?type=original&page=${pageNum}`,
});
}
// Update pageCount if not set
if (doc.pageCount === 0) {
await prisma.pdfDocument.update({
where: { id },
@@ -101,9 +65,6 @@ export async function POST(
return NextResponse.json({ pages, pageCount: numPages });
} catch (e) {
console.error("Render error:", e);
return NextResponse.json(
{ error: "Page rendering failed" },
{ status: 500 }
);
return NextResponse.json({ error: "Page rendering failed" }, { status: 500 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
/**
* Trivial liveness endpoint for container/compose healthchecks. Intentionally
* unauthenticated (excluded from the auth middleware matcher) and does not touch
* the database so it stays fast and dependency-free.
*/
export function GET() {
return NextResponse.json({ status: "ok", uptime: process.uptime() });
}
+7 -6
View File
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma";
import { writeFile, mkdir } from "fs/promises";
import { v4 as uuidv4 } from "uuid";
import path from "path";
import { uploadsDir } from "@/lib/storage";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_DOCUMENTS = 5;
@@ -53,15 +54,15 @@ export async function POST(req: NextRequest) {
}
const fileId = uuidv4();
const uploadsDir = path.join(process.cwd(), "public", "uploads", user.id);
await mkdir(uploadsDir, { recursive: true });
const dir = uploadsDir(user.id);
await mkdir(dir, { recursive: true });
const fileName = `${fileId}.pdf`;
const filePath = path.join(uploadsDir, fileName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
await writeFile(path.join(dir, fileName), buffer);
const publicPath = `/uploads/${user.id}/${fileName}`;
// Stored relative to STORAGE_DIR; served only via the authenticated /asset route.
const storageKey = `uploads/${user.id}/${fileName}`;
// Detect page count from the uploaded PDF
let pageCount = 0;
@@ -78,7 +79,7 @@ export async function POST(req: NextRequest) {
data: {
userId: user.id,
originalName: file.name,
filePath: publicPath,
filePath: storageKey,
fileSize: file.size,
pageCount,
},