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({