Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions app/api/admin/orders/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.`,
Expand Down
26 changes: 2 additions & 24 deletions app/api/shop/orders/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).
Expand Down Expand Up @@ -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.`,
Expand Down
70 changes: 70 additions & 0 deletions lib/pdf.ts
Original file line number Diff line number Diff line change
@@ -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<typeof pdfjsTypes> | 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<typeof pdfjsTypes> {
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<typeof pdfjsTypes>;
}
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<number> {
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;
}
}
Loading