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
This commit is contained in:
Amruth Pillai
2026-09-05 08:59:13 -07:00
committed by GitHub
parent d53b89ba2d
commit fa41150723
4 changed files with 131 additions and 19 deletions
+40
View File
@@ -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");
});
+14
View File
@@ -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<string>();
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.
@@ -385,7 +385,7 @@ function normalizePictureUrl(url: string, origin: string): string {
}
}
async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area, mimeType: string): Promise<Blob> {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.addEventListener("load", () => {
@@ -403,24 +403,42 @@ async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise<B
canvas.width = Math.round(pixelCrop.width);
canvas.height = Math.round(pixelCrop.height);
context.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
canvas.width,
canvas.height,
);
// Preserve transparency for lossless inputs and compressed formats for photos.
const outputType = mimeType === "image/jpeg" || mimeType === "image/webp" ? mimeType : "image/png";
const maxUploadBytes = 10 * 1024 * 1024;
return new Promise<Blob>((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<Blob>((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 {
+40
View File
@@ -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);
});