diff --git a/app/features/img-upload/image-field.server.ts b/app/features/img-upload/image-field.server.ts index e49de870f..34423d1eb 100644 --- a/app/features/img-upload/image-field.server.ts +++ b/app/features/img-upload/image-field.server.ts @@ -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; } diff --git a/app/form/image-field.ts b/app/form/image-field.ts index 5ce59e589..6230cc6f7 100644 --- a/app/form/image-field.ts +++ b/app/form/image-field.ts @@ -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();