Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/api/src/media/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"));
Expand Down
19 changes: 18 additions & 1 deletion apps/api/src/media/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ const FORMAT_INFO: Record<string, { mime: string; ext: string }> = {
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 },
Expand All @@ -38,7 +48,7 @@ export const uploadImageMulter = multer({
) {
cb(null, true);
} else {
cb(null, false);
cb(new UnsupportedMimeTypeError(file.mimetype));
}
},
});
Expand Down Expand Up @@ -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({
Expand Down
Loading