fix(attachments): download non-image attachments directly from S3#494
fix(attachments): download non-image attachments directly from S3#494nickmeinhold wants to merge 2 commits into
Conversation
CarnotCodeCarver's ReviewVerdict: REQUEST_CHANGES Summary: No real engine matches the Carnot cycle; a reviewer's job is to say how far short we are. This PR fixes the intended non-image S3 download path in broad shape, but it regresses image downloads and adds unstructured logging in a route that project instructions explicitly reserve for @kan/logger. Findings:
The Good:
The Concerns:
|
nickmeinhold
left a comment
There was a problem hiding this comment.
MaxwellMergeSlam's Review
Verdict: COMMENT
Summary: The fix is structurally right — kill the proxy, bake disposition into the signed URL — but it ships without a single test and carries a handful of latent rough edges I'd rather see polished before this becomes load-bearing.
John McClane: "Welcome to the party, pal." Nick, you're standing in your own ring with your own diff. I'm gonna hit you anyway. That's how this works.
Findings:
-
No tests. Zero. Nada.
packages/shared/src/utils/s3.ts:46now takes a fourth parameter that affects the bytes of every signed URL the system produces;packages/api/src/routers/card.ts:697-720has new branching logic per attachment type;apps/web/src/views/card/components/AttachmentThumbnails.tsx:139-156swapped a server proxy for direct browser→S3. None of it is covered by an automated test. The smoke harness in/tmp/kan-smoke.mjswas throwaway and isn't in the repo. Six months from now, a refactor ofgenerateDownloadUrlwill silently drop theResponseContentDispositionand nothing will scream until a user files a bug.Morpheus: "What you know you can't explain, but you feel it."— that's the feeling of an untested URL-generation function. -
apps/web/src/views/card/components/AttachmentThumbnails.tsx:149— apostrophe in filenames is a latent RFC 5987 violation.encodeURIComponentdoes not encode', but RFC 5987 §3.2.1 lists it as a non-attr-char that requires pct-encoding (it's the delimiter token inUTF-8''<value>). A file namedit's a test.pdfproducesfilename*=UTF-8''it's a test.pdfin the header — most browsers tolerate it, but RFC-strict parsers (some download managers, some MUAs) won't. Cheap fix: add.replace(/'/g, "%27")after theencodeURIComponentcall inpackages/shared/src/utils/s3.ts:127. -
**
packages/shared/src/utils/s3.ts:117 —forceDownload: booleanis a boolean trap.** The semantic content is "Content-Disposition: attachment" vs "Content-Disposition: inline". Boolean flags lose that meaning at every call site. With TypeScript 5 in this stack, the right shape isdisposition: 'attachment' | 'inline'— a literal union the compiler enforces. Reads better at the call site (disposition: 'attachment'vsforceDownload: true`), and adding a third disposition mode in future doesn't require renaming the field. Same insight applies to other booleans creeping into this options bag if more get added later. -
Observability went asymmetric. I added
console.errorin the upload route's catch block (good) — but the proxy I deleted was the only server-side observation point for downloads. Now if a presigned URL fails for a user, there's no server log, no metric, no nothing. Failure shifts entirely to the browser dev console where we can't see it. Net diagnostic surface: down, not up, despite the commit message framing. Not a blocker because direct-from-S3 is more resilient by construction, but if someone reports "PDFs broke again" we have less to work with than before.Carl Spackler: "It's in the hole!"— but if it isn't, you'll never know. -
Deploy-window compatibility. During the rollout, browsers with cached JS (
AttachmentThumbnails.tsxpre-fix) will fireGET /api/download/attatchment?url=...&filename=.... New server has no such route; Next.js falls through to the catch-all[workspaceSlug]/[...boardSlug]page and serves a 200 OK HTML page. The user's browser saves an HTML file namedattatchment.htmlor similar, instead of failing fast. Mitigation: leave a stub atapps/web/src/pages/api/download/attatchment.tsthat 410-Gones or 308-redirects toreq.query.urlfor one release, then delete. Or just stomach the bad-window — it's small.
The Good:
-
Correct architectural call. The proxy was a workaround for a problem AWS already solved with
ResponseContentDisposition. Removing it eliminates a network hop, a memory cliff (the 50MBarrayBuffer()at the deleted line 69), and an environment-specific failure surface (server-side fetch to S3 — the original symptom).Sarah Connor: "On a long enough timeline, the survival rate for everything drops to zero."This proxy was on that timeline. You shortened it. -
RFC 5987 ascii-fallback + UTF-8 form is correct in shape.
packages/shared/src/utils/s3.ts:124-127produces bothfilename="..."(legacy) andfilename*=UTF-8''...(modern) in one header, per the RFC. Most "force download" implementations get one form or the other; doing both is the right move for a multi-browser audience. -
Type-driven verification at the URL layer. The smoke I ran (call generator, parse result, assert query params + signature presence) actually proves the substantive change works. Most cage-match PRs ship on "trust me, it compiles." This one has signed-URL-level evidence in the conversation, and the math holds.
-
Branch isolation. Created off
main, not stacked on the openfeat/move-board-between-workspacesPR #458. Clean blast radius. -
apps/web/src/pages/api/upload/attachment.ts:140— added structuredconsole.errorwithcardPublicId,contentType,contentLengthin the catch. Future upload failures stop being silent 500s. Small change, big future-debugging dividend.
The Concerns:
-
The "no tests" smell is the load-bearing one. Everything else on this list could be a follow-up TaskCreate. The test gap turns this PR from "a fix" into "a fix that survives until the next refactor." Recommendation: at minimum a unit test on
generateAttachmentUrlasserting (a)ResponseContentDispositionappears whendisposition: 'attachment', (b) ascii fallback and UTF-8 form both present, (c) signature query param present. ~15 lines of test code; closes the loop. -
Apostrophe encoding is the only thing I'd consider a real bug (vs. design preference). The other findings are debt. This one is wrong-by-spec.
-
Tyler Durden: "The things you own end up owning you."— the proxy owned us. Killing it is the right call. Just don't underestimate that we're now wholly dependent on S3 implementingResponseContentDispositioncorrectly across whatever endpointS3_ENDPOINThappens to point at. AWS S3 ✓. MinIO ✓ (since 2018). Backblaze B2 ✓. DigitalOcean Spaces ✓. Cloudflare R2 ✓. Wasabi ✓. The compatibility surface is good but not infinite — worth a sentence in docs.
PDF (and other non-image) downloads were proxied through /api/download/attatchment, which fetched the presigned S3 URL server-side and re-streamed it with Content-Disposition: attachment. This added a second network hop whose failure modes (DNS, container networking, fetch keep-alive) showed up as a generic "failed to fetch attachment" with no underlying error logged. Images sidestepped the proxy entirely (Next/Image renders them inline), which is why the breakage appeared PDF-specific. Bake response-header overrides into the presigned S3 URL itself (ResponseContentDisposition, ResponseContentType) so the browser hits S3 directly and S3 enforces the attachment disposition. The proxy route is removed; AttachmentThumbnails downloads straight from attachment.url. Filenames use RFC 5987 (ascii fallback + filename*=UTF-8'') so non-ascii names round-trip correctly. Also adds a console.error in the upload route's catch block so future upload failures surface in dev logs instead of being swallowed as a bare 500. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…sion + design polish
CarnotCodeCarver's HIGH finding: the image-lightbox download button
shared `handleDownload` with the file-list download button, but the
single `url` field was generated with `inline` disposition for images
(needed for thumbnail/lightbox display) — so clicking "download" on a
lightboxed image opened it in a tab instead of saving (HTML `download`
attribute is ignored cross-origin; only `Content-Disposition: attachment`
in the response forces save). Pre-PR the proxy always set disposition
for both buttons; my collapse to one URL per attachment lost that.
Fix: `card.byId` now returns two presigned URLs per attachment —
`url` (inline view) and `downloadUrl` (forced attachment). Image
thumbnails/lightbox use `url`; the download button uses `downloadUrl`.
Two URLs (not one with a flag) because the same attachment serves two
semantically distinct actions.
Other cage-match items addressed:
- `forceDownload: boolean` → `disposition: "inline" | "attachment"`
literal union (Carnot + Maxwell both flagged the boolean trap)
- `(key, expiresIn?, options?)` positional triple → `(key, options)`
with `expiresIn` inside the options bag (Carnot)
- Apostrophe in filenames now pct-encoded per RFC 5987 §3.2.1, since
`encodeURIComponent` doesn't (Maxwell)
- Backslash also stripped from ascii fallback (was an oversight)
- `console.error` in upload route → `createLogger("attachment-upload")`
per project convention (Carnot)
- `@kan/logger` added to `next.config.js` transpilePackages so the
direct import from a Next page resolves cleanly
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
810846d to
ff13360
Compare
see commit message