From e64dbc08d5d26093f51ee8d6664d91af155ac72c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:40:13 +0800 Subject: [PATCH 1/4] feat(upload): reject over-limit files in the browser before transferring (FV-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An over-limit guideline PDF was transferred in full before the server answered 413 — on a large file over a clinic connection that is a long wait for a guaranteed rejection, with the limit itself never stated anywhere in the UI. - New dependency-free `src/lib/upload-limits.ts` holds the ceiling so a client component can import it; `src/lib/http.ts` (next/server) and `src/lib/env.ts` (server-only secrets) both can't cross into the bundle. - `env.MAX_UPLOAD_MB` now derives its cap and default from that constant, so the configured limit can only ever be *lower* than what the client rejects up front — the pre-check can never refuse a file the server would have accepted, and the server stays the authority. - `UploadPanel` skips an over-ceiling file with the server's own 413 wording (shared message builder, so the two can't diverge) and still uploads the rest of the batch, matching per-file outcome semantics. - The file field states "PDF only, up to 150 MB per file", wired via aria-describedby so it is announced, not just visible. The ingress pin in tests/upload-ingress-limits.test.ts now asserts the constant's value plus env's derivation from it, keeping the 151mb proxy envelope tied to the same number. Co-Authored-By: Claude Opus 4.8 --- .../DocumentManagerPanel.tsx | 24 +++- src/lib/env.ts | 5 +- src/lib/http.ts | 3 +- src/lib/upload-limits.ts | 32 +++++ tests/upload-ingress-limits.test.ts | 10 +- tests/upload-size-precheck.dom.test.tsx | 129 ++++++++++++++++++ 6 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 src/lib/upload-limits.ts create mode 100644 tests/upload-size-precheck.dom.test.tsx diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 9f8bff1d38..545d8eb2de 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useState, useRef, useId } from "react"; import Link from "next/link"; import { UploadCloud, Loader2, RefreshCw, Sparkles, ShieldCheck, ExternalLink } from "lucide-react"; import { @@ -19,6 +19,7 @@ import { } from "@/components/ui-primitives"; import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { MAX_UPLOAD_MB_CEILING, exceedsUploadSizeCeiling, uploadSizeLimitMessage } from "@/lib/upload-limits"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import type { ClinicalDocument, IngestionJob, ImportBatch } from "@/lib/types"; @@ -299,6 +300,7 @@ export function UploadPanel({ const [uploadPercent, setUploadPercent] = useState(null); const [localStatus, setLocalStatus] = useState(null); const fileInputRef = useRef(null); + const fileHintId = useId(); const displayStatus = status !== undefined ? status : localStatus; const changeStatus = setStatus || setLocalStatus; @@ -333,6 +335,22 @@ export function UploadPanel({ for (let index = 0; index < files.length; index++) { const file = files[index]; try { + // Pre-check the size before spending the transfer. The server caps every + // file at env.MAX_UPLOAD_MB, which its schema can never raise above + // MAX_UPLOAD_MB_CEILING, so an over-ceiling file is a guaranteed 413 — + // uploading it first only makes the clinician wait for the rejection. + // The rest of the batch still uploads, matching the server's per-file + // outcome semantics. + if (exceedsUploadSizeCeiling(file.size)) { + outcomes.push({ + kind: "failed", + fileName: file.name, + status: 413, + code: "payload_too_large", + message: uploadSizeLimitMessage(MAX_UPLOAD_MB_CEILING), + }); + continue; + } changeStatus( files.length === 1 ? `Uploading ${file.name}...` : `Uploading ${index + 1} of ${files.length}: ${file.name}`, ); @@ -400,10 +418,14 @@ export function UploadPanel({ accept=".pdf,application/pdf" multiple disabled={demoMode || !canUpload || uploading} + aria-describedby={fileHintId} onChange={() => changeStatus(null)} className="mt-2 block w-full text-xs font-medium text-[color:var(--text-muted)] file:mr-3 file:min-h-9 file:cursor-pointer file:rounded-md file:border file:border-[color:var(--border)] file:bg-[color:var(--surface)] file:px-3 file:text-xs file:font-semibold file:text-[color:var(--text)] file:shadow-[var(--shadow-inset)] file:transition file:hover:bg-[color:var(--surface-subtle)] disabled:opacity-50" /> +

+ PDF only, up to {MAX_UPLOAD_MB_CEILING} MB per file. +