Fix image form field rejecting uploads on browsers without canvas webp encoding

The SendouForm image field compresses the picked file client-side with
Compressor.js requesting "image/webp" output, which uses canvas.toBlob
under the hood. Per the HTML spec, browsers that can't encode the
requested type silently fall back to PNG instead of erroring (Safari /
all iOS browsers, Brave with fingerprint protection, older Android
WebViews). The resulting "data:image/png;base64," data URL rendered a
working preview but failed the zod schema's webp prefix check, showing
the user a bare "Invalid input" error with no way to upload.

Accept the png fallback in the schema and detect the actual format from
magic bytes on the server, storing the file with the matching extension
instead of always ".webp".
This commit is contained in:
Kalle
2026-06-10 21:45:42 +03:00
parent cc0f027c26
commit f8319a2f96
2 changed files with 34 additions and 14 deletions

View File

@@ -15,7 +15,7 @@ import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants";
*
* - `null` → `null` (image removed / none)
* - `EXISTING` → the unchanged `imgId` (no bytes are re-uploaded)
* - `NEW` → decodes the base64 webp, uploads it to S3 and inserts an unvalidated image row,
* - `NEW` → decodes the base64 image, uploads it to S3 and inserts an unvalidated image row,
* auto-validating it for supporters (or always when `autoValidate` is set), then returns the
* new id.
*
@@ -44,11 +44,11 @@ export async function imageFieldValueToImgId({
);
}
const buffer = dataUrlToWebpBuffer(value.dataUrl);
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl);
const uploadedFileLocation = await uploadStreamToS3(
Readable.from(buffer),
`img-${Date.now()}-${shortNanoid()}.webp`,
`img-${Date.now()}-${shortNanoid()}.${extension}`,
);
invariant(uploadedFileLocation, "Image upload failed");
const fileName = basename(uploadedFileLocation);
@@ -64,20 +64,36 @@ export async function imageFieldValueToImgId({
return img.id;
}
function dataUrlToWebpBuffer(dataUrl: string) {
function dataUrlToImageBuffer(dataUrl: string) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
invariant(isWebp(buffer), "Submitted image is not a valid webp");
const extension = imageExtensionFromMagicBytes(buffer);
invariant(extension, "Submitted image is not a valid webp or png");
return buffer;
return { buffer, extension };
}
/** Verifies the buffer's magic bytes match the webp container (`RIFF....WEBP`). */
function isWebp(buffer: Buffer) {
return (
/**
* Resolves the image format from the buffer's magic bytes. The client compresses to webp,
* but browsers without canvas webp encoding silently fall back to png.
*/
function imageExtensionFromMagicBytes(buffer: Buffer): "webp" | "png" | null {
if (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
);
) {
return "webp";
}
if (
buffer.length > 8 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
return "png";
}
return null;
}

View File

@@ -1,8 +1,12 @@
import { z } from "zod";
import { id } from "~/utils/zod";
/** Prefix every {@link imageValue} `NEW` data URL must start with (client compresses to webp). */
const IMAGE_FIELD_WEBP_DATA_URL_PREFIX = "data:image/webp;base64,";
/**
* Allowed prefixes for a {@link imageValue} `NEW` data URL. The client compresses to webp,
* but browsers without canvas webp encoding (Safari, Brave with fingerprint protection)
* silently fall back to png per the HTML spec.
*/
const IMAGE_FIELD_DATA_URL_PREFIX_REGEX = /^data:image\/(webp|png);base64,/;
/**
* Hard ceiling for a `NEW` data URL's length. Caps the JSON body size so a malicious or
@@ -14,7 +18,7 @@ const IMAGE_FIELD_MAX_DATA_URL_LENGTH = 3_000_000;
/**
* JSON-serializable value of a SendouForm `image` field. Covers every state an edit form needs:
* `null` (none / removed), an unchanged `EXISTING` image (only the id reference + a preview url
* ride in JSON, never bytes), or a newly picked `NEW` image as a base64 webp data URL.
* ride in JSON, never bytes), or a newly picked `NEW` image as a base64 webp/png data URL.
*/
export const imageValue = z
.union([
@@ -28,7 +32,7 @@ export const imageValue = z
dataUrl: z
.string()
.max(IMAGE_FIELD_MAX_DATA_URL_LENGTH)
.startsWith(IMAGE_FIELD_WEBP_DATA_URL_PREFIX),
.regex(IMAGE_FIELD_DATA_URL_PREFIX_REGEX),
}),
])
.nullable();