1import * as Option from "effect/Option";
2import * as Mime from "effect/http/Mime";
3
4const 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
18export 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.
35function 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
46function 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".
54export 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
116export 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}