| 1 | import * as Option from "effect/Option"; |
| 2 | import * as Mime from "effect/http/Mime"; |
| 3 | |
| 4 | const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { |
| 5 | "image/avif": ".avif", |
| 6 | "image/bmp": ".bmp", |
| 7 | "image/gif": ".gif", |
| 8 | "image/heic": ".heic", |
| 9 | "image/heif": ".heif", |
| 10 | "image/jpeg": ".jpg", |
| 11 | "image/jpg": ".jpg", |
| 12 | "image/png": ".png", |
| 13 | "image/svg+xml": ".svg", |
| 14 | "image/tiff": ".tiff", |
| 15 | "image/webp": ".webp", |
| 16 | }; |
| 17 | |
| 18 | export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ |
| 19 | ".avif", |
| 20 | ".bmp", |
| 21 | ".gif", |
| 22 | ".heic", |
| 23 | ".heif", |
| 24 | ".ico", |
| 25 | ".jpeg", |
| 26 | ".jpg", |
| 27 | ".png", |
| 28 | ".svg", |
| 29 | ".tiff", |
| 30 | ".webp", |
| 31 | ]); |
| 32 | |
| 33 | // Whether `code` is a character the base64 payload may contain, aside from |
| 34 | // the whitespace handled separately below. |
| 35 | function isBase64Char(code: number): boolean { |
| 36 | return ( |
| 37 | (code >= 0x61 && code <= 0x7a) || // a-z |
| 38 | (code >= 0x41 && code <= 0x5a) || // A-Z |
| 39 | (code >= 0x30 && code <= 0x39) || // 0-9 |
| 40 | code === 0x2b || // + |
| 41 | code === 0x2f || // / |
| 42 | code === 0x3d // = |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | function isBase64Whitespace(code: number): boolean { |
| 47 | return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space |
| 48 | } |
| 49 | |
| 50 | // Data URLs carry the full image payload, so this parser must never run a |
| 51 | // regex across the payload: V8's regex engine borrows the JS call stack, and |
| 52 | // matching a multi-megabyte string from a deep call stack (e.g. inside fiber |
| 53 | // execution) throws "Maximum call stack size exceeded". |
| 54 | export function parseBase64DataUrl( |
| 55 | dataUrl: string, |
| 56 | ): { readonly mimeType: string; readonly base64: string } | null { |
| 57 | const trimmed = dataUrl.trim(); |
| 58 | if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; |
| 59 | |
| 60 | const commaIndex = trimmed.indexOf(","); |
| 61 | if (commaIndex === -1) return null; |
| 62 | const header = trimmed.slice(5, commaIndex); |
| 63 | if (header.length === 0) return null; |
| 64 | |
| 65 | const headerParts: Array<string> = []; |
| 66 | for (const part of header.split(";")) { |
| 67 | const partTrimmed = part.trim(); |
| 68 | if (partTrimmed.length > 0) { |
| 69 | headerParts.push(partTrimmed); |
| 70 | } |
| 71 | } |
| 72 | if (headerParts.length < 2) { |
| 73 | return null; |
| 74 | } |
| 75 | const trailingToken = headerParts.at(-1)?.toLowerCase(); |
| 76 | if (trailingToken !== "base64") { |
| 77 | return null; |
| 78 | } |
| 79 | |
| 80 | const mimeType = headerParts[0]?.toLowerCase(); |
| 81 | if (!mimeType) return null; |
| 82 | |
| 83 | const payload = trimmed.slice(commaIndex + 1); |
| 84 | const runs: Array<string> = []; |
| 85 | let runStart = -1; |
| 86 | for (let index = 0; index < payload.length; index += 1) { |
| 87 | const code = payload.charCodeAt(index); |
| 88 | if (isBase64Char(code)) { |
| 89 | if (runStart === -1) runStart = index; |
| 90 | continue; |
| 91 | } |
| 92 | if (!isBase64Whitespace(code)) return null; |
| 93 | if (runStart !== -1) { |
| 94 | runs.push(payload.slice(runStart, index)); |
| 95 | runStart = -1; |
| 96 | } |
| 97 | } |
| 98 | if (runStart !== -1) { |
| 99 | runs.push(payload.slice(runStart)); |
| 100 | } |
| 101 | const base64 = runs.length === 1 ? runs[0]! : runs.join(""); |
| 102 | if (base64.length === 0 || base64.length % 4 !== 0) return null; |
| 103 | const firstPad = base64.indexOf("="); |
| 104 | if (firstPad !== -1) { |
| 105 | // '=' is only valid as one or two trailing padding characters; Node's |
| 106 | // decoder would otherwise silently truncate at the first '='. |
| 107 | if (base64.length - firstPad > 2) return null; |
| 108 | for (let index = firstPad; index < base64.length; index += 1) { |
| 109 | if (base64.charCodeAt(index) !== 0x3d) return null; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | return { mimeType, base64 }; |
| 114 | } |
| 115 | |
| 116 | export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { |
| 117 | const key = input.mimeType.toLowerCase(); |
| 118 | const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) |
| 119 | ? IMAGE_EXTENSION_BY_MIME_TYPE[key] |
| 120 | : undefined; |
| 121 | if (fromMime) { |
| 122 | return fromMime; |
| 123 | } |
| 124 | |
| 125 | // The registry returns bare extensions ("png"); the safe list is dotted. |
| 126 | const fromMimeExtension = Option.map(Mime.getExtension(input.mimeType), (ext) => `.${ext}`); |
| 127 | if (Option.isSome(fromMimeExtension) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension.value)) { |
| 128 | return fromMimeExtension.value; |
| 129 | } |
| 130 | |
| 131 | const fileName = input.fileName?.trim() ?? ""; |
| 132 | const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); |
| 133 | const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; |
| 134 | if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { |
| 135 | return fileNameExtension; |
| 136 | } |
| 137 | |
| 138 | return ".bin"; |
| 139 | } |