mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 10:43:46 +10:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user