diff --git a/.env.example b/.env.example index 681061da59..9d1e14c4d0 100644 --- a/.env.example +++ b/.env.example @@ -162,6 +162,12 @@ SUPABASE_IMAGE_BUCKET=clinical-images # Conservative local-first defaults. Raise only after testing your worker machine, # Supabase plan limits, and confidentiality policy. MAX_UPLOAD_MB=150 +# Optional browser mirror of MAX_UPLOAD_MB. When you lower MAX_UPLOAD_MB, set +# the same value here so the Document Manager pre-check and hint match the +# server before a large transfer starts. Clamped to 150; unset falls back to 150. +# For Railway/Docker production images this is a BUILD-TIME variable (Dockerfile +# ARG/ENV before npm run build) — mirror lowered MAX_UPLOAD_MB before rebuilding. +# NEXT_PUBLIC_MAX_UPLOAD_MB=150 # Next Proxy has a fixed 151 MiB transport envelope (150 MiB file plus # multipart framing), so values above 150 are intentionally rejected. MAX_CONCURRENT_UPLOADS=1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d8186ed458..a74792aa54 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -74,6 +74,7 @@ jobs: build-args: | NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-ci-publishable-key + NEXT_PUBLIC_MAX_UPLOAD_MB= worker-image: runs-on: ubuntu-24.04 diff --git a/Dockerfile b/Dockerfile index 94b6cc5486..6f2537677f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ # production image: # docker build \ # --build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... \ +# --build-arg NEXT_PUBLIC_MAX_UPLOAD_MB=150 \ # -t clinical-kb-app . # Server-side secrets (SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, ...) are # NEVER baked into the image — inject them at run time from the host's @@ -33,8 +34,13 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-build-publishable-key +# Optional browser upload-limit mirror (clamped client-side). Must be set at +# build time to inline into the client bundle — runtime Railway vars alone are +# not enough when operators lower MAX_UPLOAD_MB. +ARG NEXT_PUBLIC_MAX_UPLOAD_MB= ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} ENV NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY} +ENV NEXT_PUBLIC_MAX_UPLOAD_MB=${NEXT_PUBLIC_MAX_UPLOAD_MB} # The repo build script allocates an 8 GiB heap; give the builder >= 10 GiB. RUN npm run build diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index f812d44b25..67f642e614 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -222,6 +222,12 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host. setting them as service variables inlines the real values. The publishable key is public by design; the placeholder default exists so CI can build without secrets. **Production images must be built with the real publishable key.** +- `NEXT_PUBLIC_MAX_UPLOAD_MB` is also a build-time public variable (Docker + `ARG`/`ENV` before `npm run build`). When operators lower server-side + `MAX_UPLOAD_MB`, mirror the same value in `NEXT_PUBLIC_MAX_UPLOAD_MB` before + building the production image so the browser precheck rejects over-limit + files without a full transfer. Runtime-only Railway variables are not enough + for this value because Next inlines `NEXT_PUBLIC_*` at build time. - Runtime is a non-root `node` user, prod-only `node_modules`, direct `next start -H 0.0.0.0 -p $PORT` (Railway injects `$PORT`; the local port-picker script is deliberately bypassed), and a `HEALTHCHECK` against diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 545d8eb2de..0f2d284010 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -19,7 +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 { exceedsClientUploadSize, getClientMaxUploadMb, 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"; @@ -335,19 +335,19 @@ 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)) { + // Pre-check the size before spending the transfer. Prefer + // NEXT_PUBLIC_MAX_UPLOAD_MB (clamped to the ceiling) so a lowered + // operator limit matches the UI; the server still enforces + // env.MAX_UPLOAD_MB as the authority. The rest of the batch still + // uploads, matching the server's per-file outcome semantics. + const clientMaxUploadMb = getClientMaxUploadMb(); + if (exceedsClientUploadSize(file.size)) { outcomes.push({ kind: "failed", fileName: file.name, status: 413, code: "payload_too_large", - message: uploadSizeLimitMessage(MAX_UPLOAD_MB_CEILING), + message: uploadSizeLimitMessage(clientMaxUploadMb), }); continue; } @@ -424,7 +424,7 @@ export function UploadPanel({ />
- PDF only, up to {MAX_UPLOAD_MB_CEILING} MB per file. + PDF only, up to {getClientMaxUploadMb()} MB per file.