§ unswayed-backend
PDF extensions, trusted filenames & the live dev /storage route
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:
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.PDFnormalizes 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 lenientextOrMimeacceptance rule was deliberately left alone: requiring extension+MIME agreement would have rejected the common legacy case of a correct.pdfsent asoctet-stream. Acceptance didn't change; only what gets stored did. (pdfDocxstrict mode: byte-identical.)- Learn materials force
<base>.pdfunconditionally — their MIME is already validated asapplication/pdf, so the client's extension carries zero information. AHandbook.WEIRDupload stores asHandbook.pdf. - 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.
extractOrThrownow 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_DIRenv override, default<cwd>/storage-dev, gitignored), with path-shaped names creating nested dirs and traversal segments dropped. configure-app.tsmountsexpress.staticat/storageand/video/storageover that dir, so the returned URLs genuinely serve — and becauseexpress.staticderives 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.pdf → GET /storage/Handbook.pdf → 200, 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 survive —
photo.jpegandphoto.JPGstay as sent forimage/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).