import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; /** Community avatar cap (bytes). */ const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024; /** Custom-method attachment cap (bytes). */ const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; export const COMMUNITY_AVATAR_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`; export type CreateFlowUploadValidationReason = | "empty" | "tooLarge" | "svg" | "invalidType" | "undecodable"; type SniffedUploadKind = | "jpeg" | "png" | "gif" | "webp" | "pdf" | "svg" | "empty" | "unknown"; type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp"; type CreateFlowUploadValidationOk = { ok: true; kind: CreateFlowUploadRasterKind | "pdf"; mimeType: string; }; type CreateFlowUploadValidationFail = { ok: false; reason: CreateFlowUploadValidationReason; }; export type CreateFlowUploadValidationResult = | CreateFlowUploadValidationOk | CreateFlowUploadValidationFail; export class CreateFlowUploadValidationError extends Error { readonly reason: CreateFlowUploadValidationReason; constructor(reason: CreateFlowUploadValidationReason) { super(reason); this.name = "CreateFlowUploadValidationError"; this.reason = reason; } } const SVG_MIME = new Set(["image/svg+xml", "image/svg"]); export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number { return purpose === "communityAvatar" ? COMMUNITY_AVATAR_MAX_BYTES : CUSTOM_ATTACHMENT_MAX_BYTES; } function mimeTypeForSniffedKind( kind: CreateFlowUploadRasterKind | "pdf", ): string { switch (kind) { case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "pdf": return "application/pdf"; } } export function messageKeyForCreateFlowUploadReason( reason: CreateFlowUploadValidationReason, ): `errors.${CreateFlowUploadValidationReason}` { return `errors.${reason}`; } function fileLooksLikeSvg(file: File): boolean { const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? ""; if (SVG_MIME.has(declared)) return true; return /\.svgz?$/i.test(file.name); } function readU16LE(bytes: Uint8Array, offset: number): number { return bytes[offset]! | (bytes[offset + 1]! << 8); } function readU16BE(bytes: Uint8Array, offset: number): number { return (bytes[offset]! << 8) | bytes[offset + 1]!; } function readU24LE(bytes: Uint8Array, offset: number): number { return ( bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) ); } function readU32BE(bytes: Uint8Array, offset: number): number { return ( ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0 ); } function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean { if (offset + ascii.length > bytes.length) return false; for (let i = 0; i < ascii.length; i++) { if (bytes[offset + i] !== ascii.charCodeAt(i)) return false; } return true; } function looksLikeSvgBytes(bytes: Uint8Array): boolean { let i = 0; if ( bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ) { i = 3; } while ( i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0d || bytes[i] === 0x0a) ) { i += 1; } const head = new TextDecoder("utf-8", { fatal: false }) .decode(bytes.subarray(i, Math.min(i + 512, bytes.length))) .toLowerCase(); return head.includes("= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return "jpeg"; } if ( bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) { return "png"; } if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) { return "gif"; } if ( bytes.length >= 12 && startsWith(bytes, 0, "RIFF") && startsWith(bytes, 8, "WEBP") ) { return "webp"; } if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) { return "pdf"; } if (looksLikeSvgBytes(bytes)) return "svg"; return "unknown"; } function pngDimensions( bytes: Uint8Array, ): { width: number; height: number } | null { if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null; const width = readU32BE(bytes, 16); const height = readU32BE(bytes, 20); if (width < 1 || height < 1) return null; return { width, height }; } function gifDimensions( bytes: Uint8Array, ): { width: number; height: number } | null { if (bytes.length < 10) return null; const width = readU16LE(bytes, 6); const height = readU16LE(bytes, 8); if (width < 1 || height < 1) return null; return { width, height }; } function jpegDimensions( bytes: Uint8Array, ): { width: number; height: number } | null { if (bytes.length < 4) return null; let offset = 2; while (offset + 8 < bytes.length) { if (bytes[offset] !== 0xff) { offset += 1; continue; } const marker = bytes[offset + 1]!; if (marker === 0xff) { offset += 1; continue; } if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 2; continue; } if (offset + 3 >= bytes.length) return null; const size = readU16BE(bytes, offset + 2); if (size < 2) return null; const isSof = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf); if (isSof) { if (offset + 8 >= bytes.length) return null; const height = readU16BE(bytes, offset + 5); const width = readU16BE(bytes, offset + 7); if (width < 1 || height < 1) return null; return { width, height }; } offset += 2 + size; } return null; } function webpDimensions( bytes: Uint8Array, ): { width: number; height: number } | null { if (bytes.length < 20) return null; if (startsWith(bytes, 12, "VP8X")) { if (bytes.length < 30) return null; const width = readU24LE(bytes, 24) + 1; const height = readU24LE(bytes, 27) + 1; if (width < 1 || height < 1) return null; return { width, height }; } if (startsWith(bytes, 12, "VP8L")) { if (bytes.length < 25 || bytes[20] !== 0x2f) return null; const bits = bytes[21]! | (bytes[22]! << 8) | (bytes[23]! << 16) | (bytes[24]! << 24); const width = (bits & 0x3fff) + 1; const height = ((bits >> 14) & 0x3fff) + 1; if (width < 1 || height < 1) return null; return { width, height }; } if (startsWith(bytes, 12, "VP8 ")) { if (bytes.length < 30) return null; if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) { return null; } const width = readU16LE(bytes, 26) & 0x3fff; const height = readU16LE(bytes, 28) & 0x3fff; if (width < 1 || height < 1) return null; return { width, height }; } return null; } function rasterDimensions( kind: CreateFlowUploadRasterKind, bytes: Uint8Array, ): { width: number; height: number } | null { switch (kind) { case "png": return pngDimensions(bytes); case "gif": return gifDimensions(bytes); case "jpeg": return jpegDimensions(bytes); case "webp": return webpDimensions(bytes); } } function purposeAllowsKind( purpose: CreateFlowUploadPurpose, kind: CreateFlowUploadRasterKind | "pdf", ): boolean { if (kind === "pdf") return purpose === "customMethodAttachment"; return true; } /** * Size, true-type, SVG, and header-decode checks shared by the browser and * `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client * so oversized files are rejected before they are read into memory. */ export function validateCreateFlowUploadBytes( purpose: CreateFlowUploadPurpose, bytes: Uint8Array, ): CreateFlowUploadValidationResult { if (bytes.length === 0) return { ok: false, reason: "empty" }; if (bytes.length > maxBytesForPurpose(purpose)) { return { ok: false, reason: "tooLarge" }; } const sniffed = sniffCreateFlowUploadBytes(bytes); if (sniffed === "empty") return { ok: false, reason: "empty" }; if (sniffed === "svg") return { ok: false, reason: "svg" }; if (sniffed === "unknown") return { ok: false, reason: "invalidType" }; if (!purposeAllowsKind(purpose, sniffed)) { return { ok: false, reason: "invalidType" }; } if (sniffed === "pdf") { return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") }; } if (rasterDimensions(sniffed, bytes) == null) { return { ok: false, reason: "undecodable" }; } return { ok: true, kind: sniffed, mimeType: mimeTypeForSniffedKind(sniffed), }; } async function readBlobBytes(blob: Blob): Promise { if (typeof blob.arrayBuffer === "function") { return new Uint8Array(await blob.arrayBuffer()); } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { resolve(new Uint8Array(reader.result as ArrayBuffer)); }; reader.onerror = () => { reject(reader.error ?? new Error("FileReader failed")); }; reader.readAsArrayBuffer(blob); }); } async function decodeRasterInBrowser( bytes: Uint8Array, mimeType: string, ): Promise { if (typeof createImageBitmap !== "function") return true; try { const copy = new Uint8Array(bytes); const bitmap = await createImageBitmap( new Blob([copy], { type: mimeType }), ); const ok = bitmap.width > 0 && bitmap.height > 0; bitmap.close(); return ok; } catch { return false; } } /** * Client-side validation: size and SVG name/type before reading bytes, then * the same sniff/decode path as the server, plus `createImageBitmap` when * available. */ export async function validateCreateFlowUploadFile( file: File, purpose: CreateFlowUploadPurpose, ): Promise { if (file.size === 0) return { ok: false, reason: "empty" }; if (file.size > maxBytesForPurpose(purpose)) { return { ok: false, reason: "tooLarge" }; } if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" }; const bytes = await readBlobBytes(file); const result = validateCreateFlowUploadBytes(purpose, bytes); if (result.ok === false || result.kind === "pdf") return result; const decoded = await decodeRasterInBrowser(bytes, result.mimeType); if (!decoded) return { ok: false, reason: "undecodable" }; return result; }