From fa41150723a4533bc025b019281065f48638f488 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 08:59:13 -0700 Subject: [PATCH] fix: preserve photo compression during cropping and show upload limits (#3420) * fix: preserve photo compression during cropping and show upload limits * fix: bound cropped image size before upload --- apps/web/src/libs/error-message.test.ts | 40 +++++++++++++ apps/web/src/libs/error-message.ts | 14 +++++ .../-sidebar/left/sections/picture.tsx | 56 ++++++++++++------- tests/e2e/specs/picture-upload.spec.ts | 40 +++++++++++++ 4 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/specs/picture-upload.spec.ts diff --git a/apps/web/src/libs/error-message.test.ts b/apps/web/src/libs/error-message.test.ts index aa1b1c68e..acc2ced99 100644 --- a/apps/web/src/libs/error-message.test.ts +++ b/apps/web/src/libs/error-message.test.ts @@ -3,6 +3,46 @@ import { ORPCError } from "@orpc/client"; import { getOrpcErrorMessage, getReadableErrorMessage, getResumeErrorMessage } from "./error-message"; describe("getReadableErrorMessage", () => { + it("shows the upload size validation message instead of the generic oRPC error", () => { + const error = new ORPCError("BAD_REQUEST", { + message: "Input validation failed", + data: { issues: [{ message: "File size must be less than 10MB", path: [] }] }, + }); + expect(getReadableErrorMessage(error, "Failed to upload picture.")).toBe("File size must be less than 10MB"); + }); + + it("limits validation messages, removes duplicates, and ignores malformed issues", () => { + const error = new ORPCError("BAD_REQUEST", { + message: "Input validation failed", + data: { + issues: [ + null, + {}, + { message: 42 }, + { message: " " }, + ...["First", "First", "Second", "Third", "Fourth"].map((message) => ({ message })), + ], + }, + }); + expect(getReadableErrorMessage(error, "fallback")).toBe("First Second Third"); + }); + + it.each([null, {}, { issues: null }, { issues: "invalid" }, { issues: [{ message: "" }] }])( + "retains the error message for malformed validation data %j", + (data) => { + const error = new ORPCError("BAD_REQUEST", { message: "Input validation failed", data }); + expect(getReadableErrorMessage(error, "fallback")).toBe("Input validation failed"); + }, + ); + + it("does not interpret unrelated server errors as input validation", () => { + const error = new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Unexpected failure", + data: { issues: [{ message: "Internal detail" }] }, + }); + expect(getReadableErrorMessage(error, "fallback")).toBe("Unexpected failure"); + }); + it("returns the string error directly", () => { expect(getReadableErrorMessage("explicit error", "fallback")).toBe("explicit error"); }); diff --git a/apps/web/src/libs/error-message.ts b/apps/web/src/libs/error-message.ts index bb0ffa861..1e9442b76 100644 --- a/apps/web/src/libs/error-message.ts +++ b/apps/web/src/libs/error-message.ts @@ -1,6 +1,20 @@ import { ORPCError } from "@orpc/client"; export function getReadableErrorMessage(error: unknown, fallback: string): string { + if (error instanceof ORPCError && error.code === "BAD_REQUEST") { + const data: unknown = error.data; + if (typeof data === "object" && data !== null && "issues" in data && Array.isArray(data.issues)) { + const messages = new Set(); + for (const issue of data.issues) { + if (typeof issue !== "object" || issue === null || typeof issue.message !== "string") continue; + const message = issue.message.trim(); + if (message) messages.add(message); + if (messages.size === 3) break; + } + if (messages.size > 0) return [...messages].join(" "); + } + } + if (typeof error === "string" && error) return error; if (error instanceof Error && error.message) return error.message; // Better Auth client errors are plain objects ({ code, message, status }), not Error instances. diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx index 305efd491..52a94bdf4 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx @@ -385,7 +385,7 @@ function normalizePictureUrl(url: string, origin: string): string { } } -async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise { +async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area, mimeType: string): Promise { const image = await new Promise((resolve, reject) => { const element = new Image(); element.addEventListener("load", () => { @@ -403,24 +403,42 @@ async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) resolve(blob); - else reject(new Error("Canvas is empty")); - }, "image/png"); - }); + while (true) { + context.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + canvas.width, + canvas.height, + ); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => { + if (result) resolve(result); + else reject(new Error("Canvas is empty")); + }, + outputType, + 0.9, + ); + }); + if (blob.size <= maxUploadBytes) return blob; + if (canvas.width <= 1 && canvas.height <= 1) throw new Error("Cropped image exceeds the upload limit"); + + // Re-encoding even a JPEG can exceed the API limit. Reduce only oversized crops, + // drawing from the original each time to avoid accumulating resampling artifacts. + const scale = Math.min(0.9, Math.sqrt(maxUploadBytes / blob.size) * 0.95); + canvas.width = Math.max(1, Math.floor(canvas.width * scale)); + canvas.height = Math.max(1, Math.floor(canvas.height * scale)); + } } function usePictureSettingsForm(picture: PictureValues, persist: (data: PictureValues) => void) { @@ -542,7 +560,7 @@ function PictureSectionForm() { let fileToUpload: File = cropState.file; try { if (croppedAreaPixels) { - const blob = await getCroppedImageBlob(cropState.imageSrc, croppedAreaPixels); + const blob = await getCroppedImageBlob(cropState.imageSrc, croppedAreaPixels, cropState.file.type); fileToUpload = new File([blob], cropState.file.name, { type: blob.type }); } } catch { diff --git a/tests/e2e/specs/picture-upload.spec.ts b/tests/e2e/specs/picture-upload.spec.ts new file mode 100644 index 000000000..02a8be79f --- /dev/null +++ b/tests/e2e/specs/picture-upload.spec.ts @@ -0,0 +1,40 @@ +import { createSampleResumeFromDashboard } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("uploads a large JPEG after cropping without exceeding the upload limit", async ({ authPage: page }, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + await expect(page.locator("#sidebar-picture")).toBeVisible(); + + // A deterministic high-detail image: JPEG fits the upload limit, but re-encoding at higher quality does not. + const dataUrl = await page.evaluate(() => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 4400; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable"); + const pixels = context.createImageData(4400, 4400); + let seed = 42; + for (let i = 0; i < pixels.data.length; i += 4) { + for (let channel = 0; channel < 3; channel++) { + seed = (Math.imul(seed, 1664525) + 1013904223) | 0; + pixels.data[i + channel] = seed >>> 24; + } + pixels.data[i + 3] = 255; + } + context.putImageData(pixels, 0, 0); + return canvas.toDataURL("image/jpeg", 0.65); + }); + const buffer = Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64"); + expect(buffer.byteLength).toBeLessThan(10 * 1024 * 1024); + await page.locator('input[type="file"][aria-label="Upload picture"]').setInputFiles({ + name: "large-photo.jpg", + mimeType: "image/jpeg", + buffer, + }); + const cropDialog = page.getByRole("dialog", { name: "Crop picture" }); + await expect(cropDialog.locator("img")).toBeVisible(); + await cropDialog.getByRole("button", { name: "Save & Upload" }).click(); + await expect(page.locator("#sidebar-picture input[name=url]")).toHaveValue(/\/uploads\//); + await expect + .poll(() => page.locator("#sidebar-picture img").evaluate((image: HTMLImageElement) => image.naturalWidth)) + .toBeGreaterThan(0); +});