CODEX // oaoisme wiki

§ unswayed-backend

PDF extensions, trusted filenames & the live dev /storage route

updated 2026-07-05

PDF extensions, trusted filenames & the live dev /storage route

Branch: checkpoint/final-review (round 3) · Date: 2026-07-05 · Memory: repo docs/ai/memory/0043-pdf-extension-hardening.md · Builds on: Checkpoint r2

The bug, one layer deeper

Round 2 fixed the raw-upload adapter so PDFs get an extension-bearing public_id — but the extension was derived from input.filename, which was still the client browser's file.originalname, passed through untouched. Cloudinary serves a raw asset's Content-Type from the extension in the stored URL, not from the upload's declared MIME. So a user whose browser sent cv.unknown with a perfectly valid application/pdf MIME still got a .unknown asset, and the frontend's inline <iframe> viewer downloaded or blanked instead of rendering. The validators made it worse: the resume rule accepted a file when either the extension or the MIME looked right (so mis-extensioned files sailed through), and the learn-materials rule never looked at the extension at all.

The principle shipped

The stored filename follows the validated type; the client's extension is a suggestion. Three concrete moves:

  1. normalizeResumeFilename(name, mime) — when the MIME is authoritative (application/pdf, the Word MIMEs, text/plain), the stored name is rebuilt as <base>.<canonical>; a shouty .PDF normalizes too. When the MIME is a generic wrapper (application/octet-stream, the zip MIMEs some browsers use for .docx), the name passes through untouched — for those, the validator-checked extension is the only real signal. This split is why the lenient extOrMime acceptance rule was deliberately left alone: requiring extension+MIME agreement would have rejected the common legacy case of a correct .pdf sent as octet-stream. Acceptance didn't change; only what gets stored did. (pdfDocx strict mode: byte-identical.)
  2. Learn materials force <base>.pdf unconditionally — their MIME is already validated as application/pdf, so the client's extension carries zero information. A Handbook.WEIRD upload stores as Handbook.pdf.
  3. The text extractor dispatches on the normalized extension too. The new e2e caught this live: a renamed PDF hit the extractor with an extension it had never heard of and 500ed — the same trust-the-client-extension bug, one layer deeper in the same method chain. extractOrThrow now derives its dispatch extension through the same normalization, so a renamed PDF extracts as a PDF as well as storing as one.

Display metadata is exempt on purpose: the resume row's title keeps the client's original name — users recognize their own filenames; only the wire URL needed to be trustworthy.

Cover letters were checked and confirmed unaffected — their filename is the hardcoded cover-letter.pdf.

The dev-mode dead URL

When Cloudinary isn't configured, the fallback LogStorageService returned /storage/<filename> URLs that no route served — every file link 404ed in local dev, which is exactly why bugs like this survive: file-preview features were untestable without production credentials. Two options existed (make the fallback loud and fail, or make it real); failing was a non-starter because the entire e2e suite runs on this fallback. So it became real:

  • Uploads now persist to disk under devStorageDir() (DEV_STORAGE_DIR env override, default <cwd>/storage-dev, gitignored), with path-shaped names creating nested dirs and traversal segments dropped.
  • configure-app.ts mounts express.static at /storage and /video/storage over that dir, so the returned URLs genuinely serve — and because express.static derives Content-Type from the file extension, dev now reproduces Cloudinary's raw-asset semantics exactly, which is what makes the new e2e meaningful.
  • The URL shape did not change (assetUrl(filename) as ever) — a hard constraint, since a dozen e2e suites pin those stub URLs verbatim. All passed untouched.
  • Deletes unlink best-effort; the constructor logs a clear notice naming the dir and route so the fallback is loud rather than silent.

The proof that matters

The new phase-19 e2e closes the reported bug over real HTTP: upload a learn-material PDF as Handbook.WEIRD → the row stores /storage/Handbook.pdfGET /storage/Handbook.pdf200, application/pdf, exact bytes — the inline-viewer contract end to end. The resume twin does the same with a cv-weird.bin2/text/plain upload stored and served as .txt. Before this round, neither assertion was even possible: the URLs didn't serve.

The follow-up: education media & apply-documents (same day)

The two flagged same-family surfaces were fixed on owner request. They differ from resumes in one way that shaped the design: both accept mixed media (images, documents — education media has a size cap and no type validation at all; apply-documents accepts arbitrary attachments), so they got a general helper rather than the resume one: storedFilename(name, mime) in src/storage/stored-filename.ts, with a wider authoritative map (pdf/doc/docx/txt + jpeg/png/gif/webp + mp4/mov/avi — the video set deliberately matching the /video/storage/ routing in asset-url.ts).

Two refinements over the resume-scoped rule, both about avoiding churn:

  • Acceptable alternates survivephoto.jpeg and photo.JPG stay as sent for image/jpeg (Content-Type lookups are case-insensitive; the first map entry is only used when the current extension doesn't already spell the right type).
  • Generic wrappers pass through untouched, as with resumes.

Applied at education create()/update() and the apply-documents upload loop; the persisted document/education titles keep the client's original name, mirroring the resume-title precedent. E2E: an education certificate uploaded as certificate.tmp/application/pdf stores /storage/certificate.pdf and serves inline as application/pdf; an application document statement.tmp stores under statement.pdf while its title stays statement.tmp.

normalizeResumeFilename deliberately remains separate: its narrow MIME set is part of the resume validator's frozen legacy contract, and a resume upload can never legitimately carry an image/video MIME.

Remaining note

One pre-existing test pin was updated with intent: the extension-less-name case now uses a generic MIME so it still exercises genuine ''-extension dispatch (an authoritative MIME now correctly overrides it).