From a630eeae40e81e869f9fbece2a3697ef57778f4a Mon Sep 17 00:00:00 2001 From: Charles Nykamp <16085675+cqnykamp@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:38 -0500 Subject: [PATCH] fix: surface informative 415 for rejected image MIME types The multer fileFilter previously called `cb(null, false)` for unsupported MIME types, which silently dropped the file and caused the handler to emit a generic "Unsupported or missing image" 415. Users had no way to tell whether they hit the type filter or some other failure, and the allowed formats were nowhere in the response. Throw a typed `UnsupportedMimeTypeError` from the filter instead, and extend `handleUploadError` to convert it into a 415 with the allowed list in `details`. The frontend toast already surfaces `err.response.data.details`, so this lands without client changes. Closes #2940 Co-Authored-By: Claude Opus 4.7 --- apps/api/src/media/upload.test.ts | 24 +++++++++++++++++++++++- apps/api/src/media/upload.ts | 19 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/api/src/media/upload.test.ts b/apps/api/src/media/upload.test.ts index a24b242c4..160ec8951 100644 --- a/apps/api/src/media/upload.test.ts +++ b/apps/api/src/media/upload.test.ts @@ -38,7 +38,11 @@ async function createUploader() { return user; } import * as s3 from "./s3"; -import { handleUploadImage } from "./upload"; +import { + handleUploadError, + handleUploadImage, + UnsupportedMimeTypeError, +} from "./upload"; import { ALLOWED_IMAGE_MIME_TYPES, MAX_IMAGE_BYTES, @@ -343,6 +347,24 @@ describe("handleUploadImage", () => { expect((res.jsonBody as { name: string }).name).toBe("Lemon"); }); + test("415 with allowed list when fileFilter rejects the claimed MIME", () => { + const res = mockRes(); + const next = vi.fn(); + handleUploadError( + new UnsupportedMimeTypeError("image/svg+xml"), + mockReq({}), + res as unknown as Response, + next, + ); + expect(res.statusCode).toBe(StatusCodes.UNSUPPORTED_MEDIA_TYPE); + const body = res.jsonBody as { error: string; details: string }; + expect(body.error).toBe("Unsupported image type"); + expect(body.details).toBe( + "Allowed: image/jpeg, image/png, image/webp, image/gif", + ); + expect(next).not.toHaveBeenCalled(); + }); + test("rolls back the DB row and best-effort deletes S3 when putImage throws", async () => { const user = await createUploader(); vi.mocked(s3.putImage).mockRejectedValue(new Error("S3 down")); diff --git a/apps/api/src/media/upload.ts b/apps/api/src/media/upload.ts index 09c25481e..8283fbaff 100644 --- a/apps/api/src/media/upload.ts +++ b/apps/api/src/media/upload.ts @@ -29,6 +29,16 @@ const FORMAT_INFO: Record = { gif: { mime: "image/gif", ext: "gif" }, }; +// Thrown by the multer fileFilter so handleUploadError can surface a 415 +// with the actual claimed type and the allowed list, instead of the generic +// "missing file" fallback that fires when the filter silently drops the file. +export class UnsupportedMimeTypeError extends Error { + constructor(public readonly claimedMimeType: string) { + super(`Unsupported image type: ${claimedMimeType}`); + this.name = "UnsupportedMimeTypeError"; + } +} + export const uploadImageMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_IMAGE_BYTES, files: 1 }, @@ -38,7 +48,7 @@ export const uploadImageMulter = multer({ ) { cb(null, true); } else { - cb(null, false); + cb(new UnsupportedMimeTypeError(file.mimetype)); } }, }); @@ -159,6 +169,13 @@ export function handleUploadError( res: Response, next: (err?: unknown) => void, ) { + if (err instanceof UnsupportedMimeTypeError) { + res.status(StatusCodes.UNSUPPORTED_MEDIA_TYPE).json({ + error: "Unsupported image type", + details: `Allowed: ${ALLOWED_IMAGE_MIME_TYPES.join(", ")}`, + }); + return; + } if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { res.status(StatusCodes.REQUEST_TOO_LONG).json({