From 5ae698074cda9be40bad668909ff08798ed8cd40 Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 09:52:32 -0500 Subject: [PATCH 1/8] fix(resume): load pdf-parse via dynamic import compatible with Next bundling The createRequire(import.meta.url) + require("pdf-parse") combo broke module resolution inside the Next runtime (inline parse paths), so PDF resumes failed to parse while DOCX (ESM mammoth) worked. A memoized dynamic import stays external thanks to serverExternalPackages, which now also lists pdf-parse's transitive deps (pdfjs-dist, @napi-rs/canvas). Adds a committed PDF fixture so the extraction test always runs in CI. --- next.config.ts | 9 ++++- src/lib/resume/__fixtures__/sample-resume.pdf | Bin 0 -> 646 bytes src/lib/resume/extractText.test.ts | 11 +++++- src/lib/resume/extractText.ts | 36 +++++++++++------- 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 src/lib/resume/__fixtures__/sample-resume.pdf diff --git a/next.config.ts b/next.config.ts index a60cb78..32c85e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,14 @@ const nextConfig: NextConfig = { bodySizeLimit: "10mb", }, }, - serverExternalPackages: ["@prisma/client", "pg", "@google-cloud/storage", "pdf-parse"], + serverExternalPackages: [ + "@prisma/client", + "pg", + "@google-cloud/storage", + "pdf-parse", + "pdfjs-dist", + "@napi-rs/canvas", + ], async headers() { return [ { diff --git a/src/lib/resume/__fixtures__/sample-resume.pdf b/src/lib/resume/__fixtures__/sample-resume.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cbccaad78900994c76d1846ef627f38f7f0cd374 GIT binary patch literal 646 zcmZWn+fKqj5PjdTm=_XF&~|$R3?UIL(FlTVNlb_j3tgaCyQI4q@avuJMNl^T&^>cz z&gslFrVqm#dg~AhCWytBbh}_g#~lY_z!Xcx8jLAha|sp_0_gP!&tgr~@4pEp&v;H^ z33FT1-?56=XA0<9qK;f9YMGoPwbU`jJWg0&9AIuD-=h|^Iu_OoxD@+*iH5Y(p}>sd zgMzCSQAb~t%@kIX;8bXBTfb}d5zp3Y1Jo?+NtJWl7s5T-i*nXtl zRC-lAwfovv+5h6-jBvkD#W*xb!RRx|%g7^7zqm@4?5wwkoUaJBi { + it("extracts text from the committed PDF fixture", async () => { + const fixture = readFileSync(FIXTURE_PDF); + + await expect(extractTextFromResumeBuffer(fixture, "resume.pdf")).resolves.toContain( + "SCOUTLANE FIXTURE RESUME", + ); + }); + it.skipIf(!existsSync(SAMPLE_PDF))( - "extracts text through the Node pdf-parse build", + "extracts text from a real local sample resume", async () => { const sample = readFileSync(SAMPLE_PDF); diff --git a/src/lib/resume/extractText.ts b/src/lib/resume/extractText.ts index 98ae197..935a111 100644 --- a/src/lib/resume/extractText.ts +++ b/src/lib/resume/extractText.ts @@ -1,18 +1,26 @@ -import { createRequire } from "node:module"; import mammoth from "mammoth"; -const require = createRequire(import.meta.url); +type PdfParseCtor = new (input: { data: Uint8Array }) => { + getText: () => Promise<{ text?: string }>; + destroy?: () => Promise | void; +}; type PdfParseModule = { - default?: (data: Buffer) => Promise<{ text?: string }>; - PDFParse?: { - new (input: { data: Uint8Array }): { - getText: () => Promise<{ text?: string }>; - destroy?: () => Promise | void; - }; - }; + default?: ((data: Buffer) => Promise<{ text?: string }>) | { PDFParse?: PdfParseCtor }; + PDFParse?: PdfParseCtor; }; +// pdf-parse is listed in next.config.ts `serverExternalPackages`, so both +// webpack and Turbopack leave this dynamic import external and Node resolves +// the real package at runtime. (A createRequire(import.meta.url) + require() +// combo broke under the Next bundler — see commit history.) +let pdfParsePromise: Promise | null = null; + +function loadPdfParse(): Promise { + pdfParsePromise ??= import("pdf-parse") as Promise; + return pdfParsePromise; +} + function extension(filename: string): string { const base = filename.split(/[/\\]/).pop() ?? filename; const i = base.lastIndexOf("."); @@ -35,15 +43,15 @@ export async function extractTextFromResumeBuffer(buffer: Buffer, filename: stri } if (ext === "pdf") { - const mod = require("pdf-parse") as PdfParseModule; - const legacyParse = mod.default; + const mod = await loadPdfParse(); - if (typeof legacyParse === "function") { - const parsed = await legacyParse(buffer); + if (typeof mod.default === "function") { + const parsed = await mod.default(buffer); return (parsed.text ?? "").trim(); } - const PDFParse = mod.PDFParse; + const PDFParse = + mod.PDFParse ?? (typeof mod.default === "object" ? mod.default?.PDFParse : undefined); if (!PDFParse) { throw new Error("PDF parser is unavailable."); } From 133fa2d61c5fb4a1be6fa371a1434df3ef7033ea Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 09:52:57 -0500 Subject: [PATCH 2/8] chore: add .gitattributes marking binary assets to prevent EOL corruption --- .gitattributes | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd6029f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Binary assets — never apply EOL conversion +*.pdf binary +*.docx binary +*.doc binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary From 8d8d3a48183d1412d56af0bb800ee27d9cb8e48e Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 09:54:45 -0500 Subject: [PATCH 3/8] feat(llm): default OpenRouter model to google/gemini-2.5-flash with cheap fallbacks Replaces openrouter/owl-alpha default and free/auto fallbacks with google/gemini-2.5-flash -> google/gemini-2.5-flash-lite -> openrouter/auto (slugs verified against the live OpenRouter model list). Tightens the resume parse prompt for stricter, deduplicated output. --- .env.example | 7 ++++--- docs/SETUP.md | 4 ++-- src/lib/llm/openrouter.test.ts | 8 ++++---- src/lib/llm/openrouter.ts | 4 ++-- src/lib/llm/resume.ts | 3 +++ 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 7239bb1..d888957 100644 --- a/.env.example +++ b/.env.example @@ -79,9 +79,10 @@ EMAIL_FROM="ScoutLane " # Get a key at https://openrouter.ai/keys OPENROUTER_API_KEY="" -# Model identifier. `openrouter/auto` keeps parsing available if a provider model is removed. -OPENROUTER_MODEL="openrouter/owl-alpha" -OPENROUTER_FALLBACK_MODELS="openrouter/free,openrouter/auto" +# Model identifier. Defaults to google/gemini-2.5-flash when unset. +# `openrouter/auto` keeps parsing available if a provider model is removed. +OPENROUTER_MODEL="google/gemini-2.5-flash" +OPENROUTER_FALLBACK_MODELS="google/gemini-2.5-flash-lite,openrouter/auto" # Attribution headers (optional, but help with OpenRouter rate-limit visibility) OPENROUTER_APP_URL="http://localhost:3000" diff --git a/docs/SETUP.md b/docs/SETUP.md index 7dea3bc..af5b6e8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -177,8 +177,8 @@ Resume parsing uses OpenRouter through the OpenAI-compatible SDK. If `OPENROUTER ``` OPENROUTER_API_KEY="sk-or-v1-xxxxxxxxxxxxxxxxxxxx" # Optional. Defaults to the current free OpenRouter fallback used by ScoutLane. -OPENROUTER_MODEL="openrouter/owl-alpha" -OPENROUTER_FALLBACK_MODELS="openrouter/free,openrouter/auto" +OPENROUTER_MODEL="google/gemini-2.5-flash" +OPENROUTER_FALLBACK_MODELS="google/gemini-2.5-flash-lite,openrouter/auto" ``` ### 3.3 What happens when parsing fails diff --git a/src/lib/llm/openrouter.test.ts b/src/lib/llm/openrouter.test.ts index 541cb57..d03f76c 100644 --- a/src/lib/llm/openrouter.test.ts +++ b/src/lib/llm/openrouter.test.ts @@ -22,10 +22,10 @@ afterEach(() => { }); describe("OpenRouter model selection", () => { - it("uses a current free OpenRouter model as the default model", () => { + it("uses Gemini 2.5 Flash as the default model", () => { delete process.env.OPENROUTER_MODEL; - expect(getOpenRouterModel()).toBe("openrouter/owl-alpha"); + expect(getOpenRouterModel()).toBe("google/gemini-2.5-flash"); }); it("deduplicates configured models and keeps built-in fallbacks", () => { @@ -36,8 +36,8 @@ describe("OpenRouter model selection", () => { "model-a", "model-b", "openrouter/auto", - "openrouter/owl-alpha", - "openrouter/free", + "google/gemini-2.5-flash", + "google/gemini-2.5-flash-lite", ]); }); }); diff --git a/src/lib/llm/openrouter.ts b/src/lib/llm/openrouter.ts index 03856e0..7b7ec32 100644 --- a/src/lib/llm/openrouter.ts +++ b/src/lib/llm/openrouter.ts @@ -4,8 +4,8 @@ let cached: OpenAI | null | undefined; type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; -const DEFAULT_OPENROUTER_MODEL = "openrouter/owl-alpha"; -const BUILT_IN_FALLBACK_MODELS = ["openrouter/free", "openrouter/auto"]; +const DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"; +const BUILT_IN_FALLBACK_MODELS = ["google/gemini-2.5-flash-lite", "openrouter/auto"]; const DEFAULT_OPENROUTER_TIMEOUT_MS = 20_000; export function getOpenRouterClient(): OpenAI | null { diff --git a/src/lib/llm/resume.ts b/src/lib/llm/resume.ts index 0776f6f..034e6a9 100644 --- a/src/lib/llm/resume.ts +++ b/src/lib/llm/resume.ts @@ -67,6 +67,9 @@ For confidence: use "high" when the field is clearly stated in the resume, If a field is null, set its confidence to "low". Only use information present in the resume. Use null or empty arrays when missing. +Output exactly the listed keys and no others. +Lowercase the email. graduationYear must be a 4-digit year string when known. +Deduplicate skills and use canonical names (e.g. "TypeScript", not "typescript" or "TS"). Resume: `; From 93835e46d3b93ea8e0a0e6af8e1ba7b94f6139b2 Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 09:56:30 -0500 Subject: [PATCH 4/8] feat(resumes): shared disk-to-db resume resolution with content-disposition and clearer 404 Extracts the duplicated local-disk/ResumeFile resolution into src/lib/resume/storage-read.ts, reused by the serving route and the parser. The serving route now sends Content-Disposition (inline for embeddable types, attachment otherwise, ?download=1 to force) with RFC 5987 filenames, and the 404 explains the dev-vs-prod storage mismatch. --- src/app/api/resumes/[...objectName]/route.ts | 79 +++++++---------- src/lib/resume/parseApplicantResume.ts | 31 ++----- src/lib/resume/storage-read.test.ts | 93 ++++++++++++++++++++ src/lib/resume/storage-read.ts | 87 ++++++++++++++++++ 4 files changed, 215 insertions(+), 75 deletions(-) create mode 100644 src/lib/resume/storage-read.test.ts create mode 100644 src/lib/resume/storage-read.ts diff --git a/src/app/api/resumes/[...objectName]/route.ts b/src/app/api/resumes/[...objectName]/route.ts index fb919a6..44879dc 100644 --- a/src/app/api/resumes/[...objectName]/route.ts +++ b/src/app/api/resumes/[...objectName]/route.ts @@ -1,65 +1,46 @@ -import { readFile, stat } from "node:fs/promises"; -import path from "node:path"; import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db/prisma"; -import { LOCAL_RESUME_STORAGE_DIR } from "@/lib/storage/upload"; +import { canEmbedResume } from "@/lib/resume/preview"; +import { buildContentDisposition, readResumeObject } from "@/lib/resume/storage-read"; export const dynamic = "force-dynamic"; -const CONTENT_TYPES: Record = { - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".pdf": "application/pdf", - ".csv": "text/csv; charset=utf-8", -}; - -function resolveLocalResumePath(parts: string[]): string | null { - const root = path.resolve(LOCAL_RESUME_STORAGE_DIR); - const filePath = path.resolve(root, ...parts); - return filePath === root || !filePath.startsWith(`${root}${path.sep}`) ? null : filePath; -} - export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ objectName: string[] }> }, ) { const { objectName } = await params; const storedObjectName = objectName.join("/"); - const filePath = resolveLocalResumePath(objectName); - - if (!filePath) { - return NextResponse.json({ error: "Invalid resume path" }, { status: 400 }); - } + let resume; try { - const [file, info] = await Promise.all([readFile(filePath), stat(filePath)]); - const ext = path.extname(filePath).toLowerCase(); - - return new NextResponse(file, { - headers: { - "Cache-Control": "private, max-age=0, no-cache", - "Content-Length": String(info.size), - "Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream", - "X-Content-Type-Options": "nosniff", - }, - }); + resume = await readResumeObject(storedObjectName); } catch { - const stored = await prisma.resumeFile.findUnique({ - where: { objectName: storedObjectName }, - select: { contentType: true, data: true, size: true }, - }); - - if (!stored) { - return NextResponse.json({ error: "Resume not found" }, { status: 404 }); - } + return NextResponse.json({ error: "Invalid resume path" }, { status: 400 }); + } - return new NextResponse(Buffer.from(stored.data), { - headers: { - "Cache-Control": "private, max-age=0, no-cache", - "Content-Length": String(stored.size), - "Content-Type": stored.contentType, - "X-Content-Type-Options": "nosniff", + if (!resume) { + return NextResponse.json( + { + error: + "Resume file not found. It may have been uploaded in a different environment or before durable storage was enabled.", }, - }); + { status: 404 }, + ); } + + const forceDownload = request.nextUrl.searchParams.get("download") === "1"; + const disposition = + !forceDownload && canEmbedResume({ contentType: resume.contentType }) + ? "inline" + : "attachment"; + + return new NextResponse(new Uint8Array(resume.buffer), { + headers: { + "Cache-Control": "private, max-age=0, no-cache", + "Content-Disposition": buildContentDisposition(disposition, resume.filename), + "Content-Length": String(resume.size), + "Content-Type": resume.contentType, + "X-Content-Type-Options": "nosniff", + }, + }); } diff --git a/src/lib/resume/parseApplicantResume.ts b/src/lib/resume/parseApplicantResume.ts index c6dcf86..74240e1 100644 --- a/src/lib/resume/parseApplicantResume.ts +++ b/src/lib/resume/parseApplicantResume.ts @@ -1,11 +1,9 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db/prisma"; import { extractTextFromResumeBuffer } from "@/lib/resume/extractText"; +import { readResumeObject } from "@/lib/resume/storage-read"; import { parseResumeFromText, type ParsedResume } from "@/lib/llm/resume"; import { scoreApplicantInline } from "@/lib/match/scoreApplicant"; -import { LOCAL_RESUME_STORAGE_DIR } from "@/lib/storage/upload"; const MAX_PARSING_ERROR_LENGTH = 240; const PARSING_SECRET_PATTERNS: ReadonlyArray = [ @@ -109,36 +107,17 @@ function getResumeObjectName(resumeUrl: string): string | null { : null; } -function resolveLocalResumePath(objectName: string): string | null { - const root = path.resolve(LOCAL_RESUME_STORAGE_DIR); - const filePath = path.resolve(root, ...objectName.split("/")); - return filePath === root || !filePath.startsWith(`${root}${path.sep}`) ? null : filePath; -} - async function readStoredResume( resumeUrl: string, ): Promise<{ buffer: Buffer; filename: string } | null> { const objectName = getResumeObjectName(resumeUrl); if (!objectName) return null; - const filePath = resolveLocalResumePath(objectName); - if (!filePath) { - throw new Error("Invalid resume path."); - } - - const filename = objectName.split("/").pop() || "resume.pdf"; - try { - return { buffer: await readFile(filePath), filename }; - } catch { - const stored = await prisma.resumeFile.findUnique({ - where: { objectName }, - select: { data: true }, - }); - if (!stored) { - throw new Error("Could not load stored resume."); - } - return { buffer: Buffer.from(stored.data), filename }; + const stored = await readResumeObject(objectName); + if (!stored) { + throw new Error("Could not load stored resume."); } + return { buffer: stored.buffer, filename: stored.filename }; } export async function parseApplicantResumeFromBuffer( diff --git a/src/lib/resume/storage-read.test.ts b/src/lib/resume/storage-read.test.ts new file mode 100644 index 0000000..bb0f880 --- /dev/null +++ b/src/lib/resume/storage-read.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { readFileMock, findUniqueMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + findUniqueMock: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: readFileMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { resumeFile: { findUnique: findUniqueMock } }, +})); + +import { + buildContentDisposition, + readResumeObject, + resolveLocalResumePath, +} from "./storage-read"; + +beforeEach(() => { + readFileMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("readResumeObject", () => { + it("returns the local file when it exists on disk", async () => { + readFileMock.mockResolvedValue(Buffer.from("local pdf bytes")); + + const result = await readResumeObject("resumes/2026-06/jane-abc.pdf"); + + expect(result).not.toBeNull(); + expect(result!.buffer.toString()).toBe("local pdf bytes"); + expect(result!.contentType).toBe("application/pdf"); + expect(result!.filename).toBe("jane-abc.pdf"); + expect(findUniqueMock).not.toHaveBeenCalled(); + }); + + it("falls back to the database when the local file is missing", async () => { + readFileMock.mockRejectedValue(new Error("ENOENT")); + findUniqueMock.mockResolvedValue({ + contentType: "application/pdf", + data: Uint8Array.from(Buffer.from("db pdf bytes")), + filename: "jane-original.pdf", + size: 12, + }); + + const result = await readResumeObject("resumes/2026-06/jane-abc.pdf"); + + expect(result).not.toBeNull(); + expect(result!.buffer.toString()).toBe("db pdf bytes"); + expect(result!.filename).toBe("jane-original.pdf"); + expect(findUniqueMock).toHaveBeenCalledWith({ + where: { objectName: "resumes/2026-06/jane-abc.pdf" }, + select: { contentType: true, data: true, filename: true, size: true }, + }); + }); + + it("returns null when the object exists nowhere", async () => { + readFileMock.mockRejectedValue(new Error("ENOENT")); + findUniqueMock.mockResolvedValue(null); + + await expect(readResumeObject("resumes/2026-06/missing.pdf")).resolves.toBeNull(); + }); + + it("rejects path traversal", async () => { + await expect(readResumeObject("../../etc/passwd")).rejects.toThrow("Invalid resume path."); + expect(readFileMock).not.toHaveBeenCalled(); + }); +}); + +describe("resolveLocalResumePath", () => { + it("returns null for traversal and the bare root", () => { + expect(resolveLocalResumePath("..")).toBeNull(); + expect(resolveLocalResumePath("")).toBeNull(); + }); +}); + +describe("buildContentDisposition", () => { + it("emits inline with quoted and RFC 5987 filenames", () => { + expect(buildContentDisposition("inline", "resume.pdf")).toBe( + `inline; filename="resume.pdf"; filename*=UTF-8''resume.pdf`, + ); + }); + + it("sanitizes non-ascii filenames in the quoted fallback", () => { + const value = buildContentDisposition("attachment", "résumé.pdf"); + expect(value.startsWith(`attachment; filename="r_sum_.pdf"`)).toBe(true); + expect(value).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"); + }); +}); diff --git a/src/lib/resume/storage-read.ts b/src/lib/resume/storage-read.ts new file mode 100644 index 0000000..98cae09 --- /dev/null +++ b/src/lib/resume/storage-read.ts @@ -0,0 +1,87 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { prisma } from "@/lib/db/prisma"; +import { LOCAL_RESUME_STORAGE_DIR } from "@/lib/storage/upload"; + +const EXTENSION_CONTENT_TYPES: Record = { + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pdf": "application/pdf", + ".csv": "text/csv; charset=utf-8", + ".txt": "text/plain; charset=utf-8", +}; + +export interface StoredResume { + buffer: Buffer; + contentType: string; + filename: string; + size: number; +} + +/** + * Resolves a stored object name to an absolute path inside the local resume + * directory, rejecting path-traversal attempts. Returns null when the + * resolved path would escape the storage root. + */ +export function resolveLocalResumePath(objectName: string): string | null { + const root = path.resolve(LOCAL_RESUME_STORAGE_DIR); + const filePath = path.resolve(root, ...objectName.split("/")); + return filePath === root || !filePath.startsWith(`${root}${path.sep}`) ? null : filePath; +} + +function inferContentTypeFromName(name: string): string { + const ext = path.extname(name).toLowerCase(); + return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +/** + * Loads a stored resume by object name: local disk first (dev), then the + * database-backed ResumeFile table (Vercel/production). Returns null when the + * object cannot be found in either backend. Throws on path-traversal input. + */ +export async function readResumeObject(objectName: string): Promise { + const filePath = resolveLocalResumePath(objectName); + if (!filePath) { + throw new Error("Invalid resume path."); + } + + const fallbackFilename = objectName.split("/").pop() || "resume.pdf"; + + try { + const buffer = await readFile(filePath); + return { + buffer, + contentType: inferContentTypeFromName(filePath), + filename: fallbackFilename, + size: buffer.byteLength, + }; + } catch { + const stored = await prisma.resumeFile.findUnique({ + where: { objectName }, + select: { contentType: true, data: true, filename: true, size: true }, + }); + + if (!stored) { + return null; + } + + return { + buffer: Buffer.from(stored.data), + contentType: stored.contentType || inferContentTypeFromName(stored.filename), + filename: stored.filename || fallbackFilename, + size: stored.size, + }; + } +} + +/** + * Builds a Content-Disposition header value with an RFC 5987-encoded filename + * so non-ASCII applicant filenames survive the round trip. + */ +export function buildContentDisposition(kind: "inline" | "attachment", filename: string): string { + const fallback = filename.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_"); + const encoded = encodeURIComponent(filename).replace(/['()*]/g, (c) => + `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `${kind}; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} From b928d6aaa54a6bd255a3d82836cbdf280a1dad7e Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 09:59:50 -0500 Subject: [PATCH 5/8] feat(resumes): inline DOCX preview via mammoth html in sandboxed iframe Word resumes now render inline: a new authenticated route /api/resumes/preview/ converts DOCX to sanitized HTML (mammoth + sanitize-html allowlist, CSP default-src 'none') and the admin applicant page embeds it in a sandboxed iframe. PDFs keep the native iframe. Adds getResumePreviewKind() and a committed DOCX fixture. --- .../[id]/applicants/[applicantId]/page.tsx | 23 +++-- .../resumes/preview/[...objectName]/route.ts | 77 +++++++++++++++++ .../resume/__fixtures__/sample-resume.docx | Bin 0 -> 1531 bytes src/lib/resume/docx-preview.test.ts | 45 ++++++++++ src/lib/resume/docx-preview.ts | 80 ++++++++++++++++++ src/lib/resume/preview.test.ts | 31 ++++++- src/lib/resume/preview.ts | 30 +++++++ 7 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 src/app/api/resumes/preview/[...objectName]/route.ts create mode 100644 src/lib/resume/__fixtures__/sample-resume.docx create mode 100644 src/lib/resume/docx-preview.test.ts create mode 100644 src/lib/resume/docx-preview.ts diff --git a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx index e3176df..e5e857f 100644 --- a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx +++ b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx @@ -13,7 +13,7 @@ import { ApplicantResumeDataEditor } from "./_components/ApplicantResumeDataEdit import { ApplicantCustomFields, type ConfiguredCustomField } from "./_components/ApplicantCustomFields"; import { DeleteApplicantButton } from "./_components/DeleteApplicantButton"; import { InterviewDatePicker } from "@/components/applicants/InterviewDatePicker"; -import { canEmbedResume } from "@/lib/resume/preview"; +import { getResumePreviewKind } from "@/lib/resume/preview"; function matchBadgeColor(score: number | null): string { if (score === null) return "bg-slate-100 text-slate-500"; @@ -132,7 +132,8 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag // Decide inline preview by the stored MIME type (resume URLs frequently lack // a usable extension), falling back to the URL extension for externally - // hosted files. PDFs/text embed; Word documents are download-only. + // hosted files. PDFs/text embed natively; Word documents render through the + // sanitized HTML preview route. const resumeObjectName = applicant.resumeUrl ? getResumeObjectName(applicant.resumeUrl) : null; @@ -142,14 +143,19 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag select: { contentType: true }, }) : null; + const resumePreviewKind = applicant.resumeUrl + ? getResumePreviewKind({ + contentType: resumeFile?.contentType, + pathname: getResumePathname(applicant.resumeUrl), + }) + : "none"; const resumeEmbedSrc = - applicant.resumeUrl && - canEmbedResume({ - contentType: resumeFile?.contentType, - pathname: getResumePathname(applicant.resumeUrl), - }) + resumePreviewKind === "native" ? applicant.resumeUrl - : null; + : resumePreviewKind === "docx-html" && resumeObjectName + ? `/api/resumes/preview/${resumeObjectName}` + : null; + const resumeEmbedSandbox = resumePreviewKind === "docx-html" ? "" : undefined; const confidenceColors: Record = { high: "bg-emerald-100 text-emerald-700", @@ -295,6 +301,7 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag