From 09efd0db8beb0ae70d0e263e9176b081f48e9a84 Mon Sep 17 00:00:00 2001 From: Benson Cho <100653148+choden-dev@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:51:34 +1000 Subject: [PATCH] fix(orders): count PDF pages by parsing the document, not scanning bytes The server-side page counter scanned raw PDF bytes for '/Type /Page' markers to price orders. That badly overcounts real-world PDFs: the marker also appears in annotations/form fields, object streams, orphaned objects left by incremental updates, and inside content streams. A recent 200-page upload was counted as 467. Replace the naive scan with a shared lib/pdf.ts helper that parses the document with pdfjs-dist (already a dependency, already used client-side by PdfOrder and configured for the Worker runtime via the canvas shim) and reads the authoritative numPages. Both /api/shop/orders and /api/admin/orders now use the shared helper; invalid/corrupt files still return 0 so callers reject them. --- app/api/admin/orders/route.ts | 26 +------------ app/api/shop/orders/route.ts | 26 +------------ lib/pdf.ts | 70 +++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 lib/pdf.ts diff --git a/app/api/admin/orders/route.ts b/app/api/admin/orders/route.ts index 4c29a28..fe5106b 100644 --- a/app/api/admin/orders/route.ts +++ b/app/api/admin/orders/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { isPayloadAdmin } from "../../../../lib/auth"; import { getPayloadClient } from "../../../../lib/payload"; +import { countPdfPages } from "../../../../lib/pdf"; import { cleanupStagingFiles, downloadFromStaging, @@ -21,29 +22,6 @@ export const runtime = "nodejs"; // HEAD + page-counting downloads of every file can take a while at max size. export const maxDuration = 60; -/** - * Count PDF pages by scanning for /Type /Page markers in the raw buffer. - * Lightweight — no external dependencies, no Object.defineProperty issues. - */ -function countPdfPages(buffer: Buffer): number { - const text = buffer.toString("latin1"); - let count = 0; - let pos = 0; - - while (true) { - const idx = text.indexOf("/Type", pos); - if (idx === -1) break; - - const after = text.substring(idx + 5, idx + 30).trimStart(); - if (after.startsWith("/Page") && !after.startsWith("/Pages")) { - count++; - } - pos = idx + 5; - } - - return Math.max(count, 0); -} - /** * POST /api/admin/orders — Admin-only: manually finalise a DRAFT order on * behalf of a customer from staging files the admin already uploaded directly @@ -229,7 +207,7 @@ export async function POST(request: NextRequest) { const orderFiles = await Promise.all( headed.map(async (v) => { const buffer = await downloadFromStaging(v.stagingKey); - const pageCount = countPdfPages(buffer); + const pageCount = await countPdfPages(buffer); if (pageCount < 1) { throw new Error( `"${v.fileName}": Unable to read PDF. Please ensure it's a valid PDF file.`, diff --git a/app/api/shop/orders/route.ts b/app/api/shop/orders/route.ts index 3b8f113..8137ab6 100644 --- a/app/api/shop/orders/route.ts +++ b/app/api/shop/orders/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getAuthenticatedCustomer } from "../../../../lib/auth"; import { getPayloadClient } from "../../../../lib/payload"; +import { countPdfPages } from "../../../../lib/pdf"; import { cleanupStagingFiles, downloadFromStaging, @@ -21,29 +22,6 @@ export const runtime = "nodejs"; // HEAD + page-counting downloads of every file can take a while at max size. export const maxDuration = 60; -/** - * Count PDF pages by scanning for /Type /Page markers in the raw buffer. - * Lightweight — no external dependencies, no Object.defineProperty issues. - */ -function countPdfPages(buffer: Buffer): number { - const text = buffer.toString("latin1"); - let count = 0; - let pos = 0; - - while (true) { - const idx = text.indexOf("/Type", pos); - if (idx === -1) break; - - const after = text.substring(idx + 5, idx + 30).trimStart(); - if (after.startsWith("/Page") && !after.startsWith("/Pages")) { - count++; - } - pos = idx + 5; - } - - return Math.max(count, 0); -} - /** * POST /api/shop/orders — Finalise a DRAFT order from staging files the * browser already uploaded directly to R2 (see /api/shop/staging-urls). @@ -202,7 +180,7 @@ export async function POST(request: NextRequest) { const orderFiles = await Promise.all( headed.map(async (v) => { const buffer = await downloadFromStaging(v.stagingKey); - const pageCount = countPdfPages(buffer); + const pageCount = await countPdfPages(buffer); if (pageCount < 1) { throw new Error( `"${v.fileName}": Unable to read PDF. Please ensure it's a valid PDF file.`, diff --git a/lib/pdf.ts b/lib/pdf.ts new file mode 100644 index 0000000..6f1e90e --- /dev/null +++ b/lib/pdf.ts @@ -0,0 +1,70 @@ +/** + * Server-side PDF utilities. + * + * Page counting is used to price orders, so it must be accurate and must never + * trust client-supplied counts. Previously this scanned the raw PDF bytes for + * `/Type /Page` markers, but that badly overcounts real-world PDFs: the marker + * also appears in object streams, annotations/form fields, orphaned objects + * left by incremental updates, and even inside content streams. A 200-page PDF + * could be counted as 467. + * + * We now parse the document with pdfjs-dist (already a dependency, used + * client-side by the PdfOrder component) and read the authoritative page count + * from the parsed document. + */ + +import type * as pdfjsTypes from "pdfjs-dist"; + +let pdfjsPromise: Promise | null = null; + +/** + * Lazily load pdfjs-dist. Importing at module scope can break the Cloudflare + * Workers runtime during bundling/SSR, so we defer to first use. The `canvas` + * native dependency is stubbed via pnpm.overrides (see shims/canvas), which is + * fine because we only parse the document structure here — we never render. + */ +function getPdfjs(): Promise { + if (!pdfjsPromise) { + // Use the legacy build: it targets Node/non-DOM environments and does + // not rely on browser-only globals, which suits server-side parsing. + pdfjsPromise = import( + "pdfjs-dist/legacy/build/pdf.mjs" + ) as unknown as Promise; + } + return pdfjsPromise; +} + +/** + * Count the number of pages in a PDF by parsing it with pdfjs. + * + * @param buffer Raw PDF bytes. + * @returns The authoritative page count, or 0 if the file cannot be parsed as a + * valid PDF (callers treat < 1 as an invalid file). + */ +export async function countPdfPages(buffer: Buffer): Promise { + try { + const pdfjs = await getPdfjs(); + // pdfjs mutates the underlying buffer, so hand it a fresh copy. It also + // expects a Uint8Array, not a Node Buffer view with a shared pool. + const data = new Uint8Array( + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ), + ); + const doc = await pdfjs.getDocument({ + data, + // Server-side hardening: don't fetch external resources, don't rely + // on a worker thread, and avoid eval-based font handling. + isEvalSupported: false, + useWorkerFetch: false, + disableFontFace: true, + }).promise; + const { numPages } = doc; + await doc.destroy(); + return typeof numPages === "number" && numPages > 0 ? numPages : 0; + } catch { + // Corrupt/encrypted/non-PDF input — signal invalid to the caller. + return 0; + } +}