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>
90 lines
2.6 KiB
TypeScript
90 lines
2.6 KiB
TypeScript
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 { 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;
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: session.user.email },
|
|
include: { documents: { orderBy: { createdAt: "asc" } } },
|
|
});
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const formData = await req.formData();
|
|
const file = formData.get("pdf") as File | null;
|
|
|
|
if (!file) {
|
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
}
|
|
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return NextResponse.json(
|
|
{ error: "File exceeds 10 MB limit" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
|
return NextResponse.json(
|
|
{ error: "Only PDF files are accepted" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Enforce 5-document limit — delete oldest if at cap
|
|
if (user.documents.length >= MAX_DOCUMENTS) {
|
|
const oldest = user.documents[0];
|
|
await prisma.pdfDocument.delete({ where: { id: oldest.id } });
|
|
// Note: could also delete files from disk here
|
|
}
|
|
|
|
const fileId = uuidv4();
|
|
const dir = uploadsDir(user.id);
|
|
await mkdir(dir, { recursive: true });
|
|
|
|
const fileName = `${fileId}.pdf`;
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
await writeFile(path.join(dir, fileName), buffer);
|
|
|
|
// 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;
|
|
try {
|
|
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
|
|
const pdfDoc = await loadingTask.promise;
|
|
pageCount = pdfDoc.numPages;
|
|
} catch {
|
|
// fallback: if pdfjs fails, just store 0
|
|
}
|
|
|
|
const doc = await prisma.pdfDocument.create({
|
|
data: {
|
|
userId: user.id,
|
|
originalName: file.name,
|
|
filePath: storageKey,
|
|
fileSize: file.size,
|
|
pageCount,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({ document: doc }, { status: 201 });
|
|
}
|