From 841f47f6ca25e79dc56dc1e735afa37f02023f99 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 25 Jul 2026 10:46:50 +0800 Subject: [PATCH] fix(files): decode CLI-decoded formats before URL-import preview generation (#637) fetch-urls.ts called sharp(buffer).webp() directly on the raw, undecoded buffer for CLI-decoded formats (HEIC, RAW, PSD, ...), which throws and gets silently swallowed, so URL-imported files in those formats came back with no preview and 0x0 dimensions. Decodes once before both preview generation and dimension lookup, mirroring the pattern already used elsewhere in the codebase. Fixes #634 --- apps/api/src/routes/fetch-urls.ts | 53 ++++++- tests/integration/platform/fetch-urls.test.ts | 139 ++++++++++++++++-- 2 files changed, 177 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/fetch-urls.ts b/apps/api/src/routes/fetch-urls.ts index 3d900716..c780c59f 100644 --- a/apps/api/src/routes/fetch-urls.ts +++ b/apps/api/src/routes/fetch-urls.ts @@ -16,6 +16,8 @@ import sharp from "sharp"; import { z } from "zod"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; +import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; +import { decodeHeic } from "../lib/heic-converter.js"; import { putObject } from "../lib/object-storage.js"; import { FETCH_TIMEOUT_MS, @@ -254,12 +256,57 @@ async function fetchSingleUrl( : responseContentType || "application/octet-stream"; const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`; + // Formats in CLI_DECODED_FORMATS (HEIC, RAW, PSD, TGA, ...) validate + // successfully without Sharp ever decoding pixels -- validateImageBuffer + // reports width/height as 0 for them by design. Decode once here (to a + // Sharp-compatible PNG buffer) so both the preview and the real + // dimensions below can be computed from actual pixel data, the same + // pre-processing every other Sharp-touching consumer of + // validateImageBuffer() already does (image-input.ts, erase-object.ts, + // user-files.ts thumbnails). Decode failure is non-fatal: fall back to + // the raw buffer, which preserves today's behavior (skip preview, 0x0 + // dimensions) for whatever edge case failed to decode. + let decodedBuffer: Buffer = buffer; + let didDecode = false; + if (validation?.valid) { + try { + if (validation.format === "heif") { + decodedBuffer = Buffer.from(await decodeHeic(buffer)); + didDecode = true; + } else if (needsCliDecode(validation.format)) { + decodedBuffer = Buffer.from(await decodeToSharpCompat(buffer, validation.format)); + didDecode = true; + } + } catch { + // Decode failed -- non-fatal, fall back to the raw buffer below. + decodedBuffer = buffer; + didDecode = false; + } + } + + // Real dimensions for formats that just got decoded above; every other + // valid format already has real width/height from validateImageBuffer. + let width = validation?.valid ? validation.width : undefined; + let height = validation?.valid ? validation.height : undefined; + if (didDecode) { + try { + const metadata = await sharp(decodedBuffer).metadata(); + width = metadata.width ?? width; + height = metadata.height ?? height; + } catch { + // Metadata read failed -- non-fatal, keep the 0x0 fallback. + } + } + // Generate a webp preview only for non-browser-native image formats. // Non-image media has no image preview; the UI shows a modality icon. + // Runs against decodedBuffer so CLI-decoded formats (HEIC, RAW, PSD, + // ...) produce a real preview instead of failing on the raw, undecoded + // buffer. let previewUrl: string | null = null; if (validation?.valid && !BROWSER_PREVIEWABLE.has(contentType)) { try { - const previewBuffer = await sharp(buffer).webp({ quality: 80 }).toBuffer(); + const previewBuffer = await sharp(decodedBuffer).webp({ quality: 80 }).toBuffer(); const previewFilename = `preview-${filename.replace(/\.[^.]+$/, "")}.webp`; await putObject(`uploads/${jobId}/${previewFilename}`, previewBuffer); previewUrl = `/api/v1/download/${jobId}/${encodeURIComponent(previewFilename)}`; @@ -274,8 +321,8 @@ async function fetchSingleUrl( filename, contentType, size: buffer.length, - width: validation?.valid ? validation.width : undefined, - height: validation?.valid ? validation.height : undefined, + width, + height, downloadUrl, previewUrl, }; diff --git a/tests/integration/platform/fetch-urls.test.ts b/tests/integration/platform/fetch-urls.test.ts index 448073be..aa05b568 100644 --- a/tests/integration/platform/fetch-urls.test.ts +++ b/tests/integration/platform/fetch-urls.test.ts @@ -13,10 +13,19 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js"; const JPG = readFixture(fixtures.image.base.jpg100); const TIFF = readFixture(fixtures.image.formats("tiff")); -// PSD passes validateImageBuffer (CLI-decoded format, no Sharp dimension -// check) but Sharp cannot re-encode it to webp, so the route's preview -// generation catch block fires while the result still succeeds. +// PSD is a CLI-decoded format (file-validation.ts skips the Sharp dimension +// check for it). The route decodes it via decodeToSharpCompat() before +// generating a preview, so this fixture exercises that decode path. const PSD = readFixture(fixtures.image.formats("psd")); +// Real iPhone-style HEIC (HEVC-encoded). Sharp's bundled libheif cannot +// decode HEVC pixels, so this exercises the decodeHeic() pre-processing +// step the route now runs before preview generation and dimension lookup. +const HEIC = readFixture(fixtures.image.base.heic200); +// A real PSD header with its image data sliced off: magic bytes ("8BPS") +// still detect as "psd" in validateImageBuffer, but the CLI decoder has no +// pixel data to work with and fails. Exercises the route's non-fatal +// decode-failure fallback (same posture as the pre-existing preview catch). +const PSD_TRUNCATED = PSD.subarray(0, 200); let testApp: TestApp; let app: TestApp["app"]; @@ -78,10 +87,23 @@ function createMockFetch() { // encoding, so decodeURIComponent throws and filenameFromUrl falls back. case "/bad%name.jpg": return mockResponse(JPG, { headers: { "Content-Type": "image/jpeg" } }); - // PSD validates as an image but is not browser-previewable and Sharp - // cannot encode it -> preview generation catch block, success stays true. + // PSD validates as an image but is not browser-previewable; the route + // must decode it via decodeToSharpCompat() before Sharp can re-encode + // it to a webp preview. case "/layers.psd": return mockResponse(PSD, { headers: { "Content-Type": "image/vnd.adobe.photoshop" } }); + // Real HEVC-encoded HEIC, the format iPhones actually produce. Sharp's + // bundled libheif cannot decode HEVC pixels directly; the route must + // run decodeHeic() first. + case "/photo.heic": + return mockResponse(HEIC, { headers: { "Content-Type": "image/heic" } }); + // Validates as PSD (magic bytes intact) but the CLI decoder fails on + // the missing image data -> the route's decode step throws and must + // fall back to the raw buffer without failing the whole request. + case "/broken.psd": + return mockResponse(PSD_TRUNCATED, { + headers: { "Content-Type": "image/vnd.adobe.photoshop" }, + }); // Non-image body with NO content-type header at all: contentType falls // back to application/octet-stream. case "/blob.bin": @@ -450,7 +472,7 @@ describe("POST /api/v1/fetch-urls", () => { expect(result.filename).toMatch(/^file-[0-9a-f]{8}$/); }); - it("succeeds without a preview when preview generation fails (PSD)", async () => { + it("decodes a CLI-decoded format (PSD) to produce a real preview and real dimensions", async () => { const res = await app.inject({ method: "POST", url: "/api/v1/fetch-urls", @@ -467,13 +489,24 @@ describe("POST /api/v1/fetch-urls", () => { expect(result.filename).toBe("layers.psd"); // MIME comes from the detected format, not the response header. expect(result.contentType).toBe("image/vnd.adobe.photoshop"); - // CLI-decoded format: dimensions are reported as 0, not undefined. - expect(result.width).toBe(0); - expect(result.height).toBe(0); - // Sharp cannot encode PSD to webp, so the preview catch swallowed the - // error and left previewUrl null. The file itself is still saved. - expect(result.previewUrl).toBeNull(); + // The route decodes the PSD via decodeToSharpCompat() before computing + // dimensions, so these reflect the real decoded image, not 0x0. + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + // A real webp preview is generated from the decoded buffer. + expect(result.previewUrl).toBeTruthy(); + expect(result.previewUrl).toContain("preview-"); + expect(result.previewUrl).toContain(".webp"); + const previewRes = await app.inject({ + method: "GET", + url: result.previewUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(previewRes.statusCode).toBe(200); + expect(previewRes.headers["content-type"]).toBe("image/webp"); + + // The original, undecoded PSD is still what gets saved for download. const dl = await app.inject({ method: "GET", url: result.downloadUrl, @@ -483,6 +516,88 @@ describe("POST /api/v1/fetch-urls", () => { expect(dl.rawPayload.length).toBe(PSD.length); }); + it("decodes a real HEIC (HEVC) image to produce a real preview and real dimensions", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { urls: [`${MOCK_ORIGIN}/photo.heic`] }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + + const result = body.results[0]; + expect(result.success).toBe(true); + expect(result.filename).toBe("photo.heic"); + expect(result.contentType).toBe("image/heic"); + // The route decodes the HEIC via decodeHeic() before computing + // dimensions, so these reflect the real decoded image (200x150), not 0x0. + expect(result.width).toBe(200); + expect(result.height).toBe(150); + // Sharp's bundled libheif cannot re-encode HEVC pixels directly; the + // route must decode first, so a real webp preview comes back non-null. + expect(result.previewUrl).toBeTruthy(); + expect(result.previewUrl).toContain("preview-"); + expect(result.previewUrl).toContain(".webp"); + + const previewRes = await app.inject({ + method: "GET", + url: result.previewUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(previewRes.statusCode).toBe(200); + expect(previewRes.headers["content-type"]).toBe("image/webp"); + + // The original, undecoded HEIC is still what gets saved for download. + const dl = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBe(HEIC.length); + }); + + it("succeeds without a preview when decoding a CLI-decoded format fails", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { urls: [`${MOCK_ORIGIN}/broken.psd`] }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + + const result = body.results[0]; + // A per-URL decode failure is a soft failure, not a request failure: + // the fetch itself succeeded, so the result still reports success with + // the pre-decode fallback data, matching the resilience posture of the + // pre-existing preview try/catch. + expect(result.success).toBe(true); + expect(result.filename).toBe("broken.psd"); + expect(result.contentType).toBe("image/vnd.adobe.photoshop"); + // Decode failed, so dimensions fall back to validateImageBuffer's 0x0 + // rather than throwing or failing the whole request. + expect(result.width).toBe(0); + expect(result.height).toBe(0); + // No preview could be generated from the undecoded, broken buffer. + expect(result.previewUrl).toBeNull(); + + // The raw fetched bytes are still saved for download despite the + // decode failure. + const dl = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBe(PSD_TRUNCATED.length); + }); + it("defaults contentType to application/octet-stream when no header is present", async () => { const res = await app.inject({ method: "POST",