From 098ed50d0693ba181fae6f934bf427079e1f8e9f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 25 Jul 2026 10:45:12 +0800 Subject: [PATCH] fix(image): decode real iPhone HEIC files instead of rejecting them at validation (#631) validateImageBuffer() never listed heif in CLI_DECODED_FORMATS, so real iPhone HEIC uploads hit Sharp's own metadata probe (its bundled libheif only supports AV1/AVIF) and got rejected before reaching the working heif-convert/heif-dec decode path already wired up downstream. Adds heif to that set, same as raw/psd/tga/bmp/etc. Also fixes the same gap on erase-object's mask input, which validates through the same function but had no matching decode step, so a HEIC mask reached an unguarded sharp() call and came back as a misclassified server error instead of a clean 422. Fixes #622 --- apps/api/src/lib/file-validation.ts | 1 + apps/api/src/routes/tools/erase-object.ts | 12 +++++++ .../tools/image/erase-object-mask.test.ts | 34 +++++++++++++++++++ .../unit/api/file-validation-mutation.test.ts | 16 +++++---- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 42cfc463..95a8afa8 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -175,6 +175,7 @@ const CLI_DECODED_FORMATS = new Set([ "ppm", "pgm", "pbm", + "heif", ]); /** diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index fd185191..b172400c 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -172,6 +172,17 @@ export function registerEraseObject(app: FastifyInstance) { imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format); } imageBuffer = await autoOrient(imageBuffer); + + // The mask goes through the same CLI-decoded formats the main image + // does. Without this, a HEIC/RAW/etc. mask reaches the unguarded + // sharp() calls inside inpaint() and fails with a generic, misclassified + // error instead of the clear message the image gets right above. + if (maskValidation.format === "heif") { + maskBuffer = await decodeHeic(maskBuffer); + } + if (needsCliDecode(maskValidation.format)) { + maskBuffer = await decodeToSharpCompat(maskBuffer, maskValidation.format); + } } catch (err) { request.log.error({ err, toolId: "erase-object" }, "Input decoding failed"); return reply.status(422).send({ @@ -188,6 +199,7 @@ export function registerEraseObject(app: FastifyInstance) { } else { await putObject(imageKey, imageBuffer); } + await putObject(maskKey, maskBuffer); // Enqueue with both image and mask as inputRefs; the worker handler // reads them via getObjectBuffer. diff --git a/tests/integration/tools/image/erase-object-mask.test.ts b/tests/integration/tools/image/erase-object-mask.test.ts index 1318c399..1ecfb51c 100644 --- a/tests/integration/tools/image/erase-object-mask.test.ts +++ b/tests/integration/tools/image/erase-object-mask.test.ts @@ -26,8 +26,20 @@ vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) return { ...mod, isToolInstalled: () => true }; }); +// Spies on the real decodeHeic (still calls through to the actual heif-convert +// CLI) so the mask-decode regression test below can assert it fires for a +// HEIC mask, the same way it already fires for a HEIC main image. +vi.mock("../../../../apps/api/src/lib/heic-converter.js", async (importOriginal) => { + const mod = + await importOriginal(); + return { ...mod, decodeHeic: vi.fn(mod.decodeHeic) }; +}); + +import { decodeHeic } from "../../../../apps/api/src/lib/heic-converter.js"; + const JPG = readFixture(fixtures.image.base.jpg100); const PNG = readFixture(fixtures.image.base.png200); +const HEIC = readFixture(fixtures.image.base.heic200); // Large first file: widens the window in which the request stream fully // buffers (firing "close") while the first part is still streaming to // storage, which is what dropped the trailing mask part. @@ -65,6 +77,28 @@ describe("erase-object multipart contract", () => { expect(res.statusCode, res.body).toBeLessThan(400); }); + it("decodes a HEIC mask before enqueueing, mirroring the main image's decode path", { + timeout: 60_000, + }, async () => { + vi.mocked(decodeHeic).mockClear(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG }, + { name: "mask", filename: "mask.heic", contentType: "image/heic", content: HEIC }, + { name: "clientJobId", content: randomUUID() }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image/erase-object", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload: body, + }); + expect(res.statusCode, res.body).toBeLessThan(400); + // The main image here is a plain JPG, so a single decodeHeic call means + // the mask (and only the mask) was decoded. + expect(vi.mocked(decodeHeic)).toHaveBeenCalledTimes(1); + expect(vi.mocked(decodeHeic)).toHaveBeenCalledWith(HEIC); + }); + it("keeps trailing parts across reused keep-alive connections", async () => { // Regression for the @fastify/multipart parts() race: its iterator ends // on the REQUEST stream's "close", which on a warm keep-alive connection diff --git a/tests/unit/api/file-validation-mutation.test.ts b/tests/unit/api/file-validation-mutation.test.ts index 81982d53..c60bd653 100644 --- a/tests/unit/api/file-validation-mutation.test.ts +++ b/tests/unit/api/file-validation-mutation.test.ts @@ -430,15 +430,17 @@ describe("validateImageBuffer - ftyp brand verification", () => { expectRejected(await validateImageBuffer(buf), "Unrecognized image format"); }); + // Uses a synthetic buffer, not a real fixture, because sharp's metadata() + // reads HEIF dimensions from the box-level `ispe` data and succeeds on real + // HEVC-coded fixtures regardless of codec support (only pixel decode needs + // it). A real fixture here would pass even without the fix below. const heifBrands = ["heic", "heix", "mif1", "msf1", "hevc", "hevx"]; for (const brand of heifBrands) { - it(`accepts the heif brand "${brand}" (detected, then synthetic decode fails)`, async () => { - // Each brand must pass the L349 includes() check; on a synthetic buffer - // the format is heif (not CLI-decoded) and sharp fails -> metadata error. - expectRejected( - await validateImageBuffer(withFtypBrand(brand)), - "Failed to read image metadata", - ); + it(`accepts the heif brand "${brand}" and returns early without a sharp decode`, async () => { + // Each brand must pass the L349 includes() check; heif is CLI-decoded + // (real HEVC decode happens later via decodeHeic()), so this must return + // valid immediately, the same as the CR3->raw arm below. + expectValid(await validateImageBuffer(withFtypBrand(brand)), "heif", 0, 0); }); }