From a1bf02ecdf98bf9129075d712ce623adb9ea1792 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:47:37 +0000 Subject: [PATCH] fix(materials): stop losing uploaded files on redeploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPLOAD_DIR was never set for the production container, so storage.ts fell back to `$cwd/data/uploads` — /app/data/uploads inside the runner image — instead of the /data/uploads volume that docker-compose.yml actually mounts. Every restart/redeploy wiped that directory, leaving material rows pointing at files that no longer exist on disk. Reading one then errored mid-stream, which the reverse proxy in front of the app turns into a 502 for the client ("Unexpected server response (502)" in the PDF viewer). Set UPLOAD_DIR=/data/uploads in the Dockerfile to match the mkdir'd, chown'd, volume-mounted path. Also make the file route check the file exists before streaming, returning a clean 404 instead of an aborted connection if it's ever missing again. --- Dockerfile | 4 ++++ src/app/api/materials/[id]/file/route.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0647e74..37e9af4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,5 +34,9 @@ USER app EXPOSE 3000 ENV PORT=3000 HOSTNAME=0.0.0.0 +# Must match the volume mount in docker-compose.yml. Without this, uploads +# default to `$cwd/data/uploads` (i.e. /app/data/uploads here) — not the +# persisted volume — so files vanish on every container restart/redeploy. +ENV UPLOAD_DIR=/data/uploads ENTRYPOINT ["sh", "./docker-entrypoint.sh"] diff --git a/src/app/api/materials/[id]/file/route.ts b/src/app/api/materials/[id]/file/route.ts index 81b1bff..bc519a6 100644 --- a/src/app/api/materials/[id]/file/route.ts +++ b/src/app/api/materials/[id]/file/route.ts @@ -19,7 +19,16 @@ export async function GET( return new Response("Not found", { status: 404 }) } - const size = row.sizeBytes ?? (await fileSize(row.storagePath)) + // Confirm the file actually exists on disk before streaming: if it's + // missing (e.g. storage misconfiguration), fail fast with a clean 404 + // instead of erroring mid-stream, which surfaces to clients as a broken + // connection / 502 through the reverse proxy. + let size: number + try { + size = await fileSize(row.storagePath) + } catch { + return new Response("Not found", { status: 404 }) + } // Re-sanitize at serve time so rows created before the upload-side // sanitization can't serve active content either. const mime = safeInlineMime(row.mimeType)