Skip to content
Source
Sign inSign up

imageMime.ts

132 lines3.7 KB

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​
33function isBase64Char(code: number): boolean {
34 return (
35 (code >= 0x61 && code <= 0x7a) ||
36 (code >= 0x41 && code <= 0x5a) ||
37 (code >= 0x30 && code <= 0x39) ||
38 code === 0x2b ||
39 code === 0x2f ||
40 code === 0x3d
41 );
43​
44function isBase64Whitespace(code: number): boolean {
45 return code === 0x0d || code === 0x0a || code === 0x20;
47​
48// V8 regexes use the JS stack; scanning image payloads can overflow a deep fiber stack.
49export function parseBase64DataUrl(
50 dataUrl: string,
51): { readonly mimeType: string; readonly base64: string } | null {
52 const trimmed = dataUrl.trim();
53 if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null;
54​
55 const commaIndex = trimmed.indexOf(",");
56 if (commaIndex === -1) return null;
57 const header = trimmed.slice(5, commaIndex);
58 if (header.length === 0) return null;
59​
60 const headerParts: Array<string> = [];
61 for (const part of header.split(";")) {
62 const partTrimmed = part.trim();
63 if (partTrimmed.length > 0) {
64 headerParts.push(partTrimmed);
65 }
66 }
67 if (headerParts.length < 2) {
68 return null;
69 }
70 const trailingToken = headerParts.at(-1)?.toLowerCase();
71 if (trailingToken !== "base64") {
72 return null;
73 }
74​
75 const mimeType = headerParts[0]?.toLowerCase();
76 if (!mimeType) return null;
77​
78 const payload = trimmed.slice(commaIndex + 1);
79 const runs: Array<string> = [];
80 let runStart = -1;
81 for (let index = 0; index < payload.length; index += 1) {
82 const code = payload.charCodeAt(index);
83 if (isBase64Char(code)) {
84 if (runStart === -1) runStart = index;
85 continue;
86 }
87 if (!isBase64Whitespace(code)) return null;
88 if (runStart !== -1) {
89 runs.push(payload.slice(runStart, index));
90 runStart = -1;
91 }
92 }
93 if (runStart !== -1) {
94 runs.push(payload.slice(runStart));
95 }
96 const base64 = runs.length === 1 ? runs[0]! : runs.join("");
97 if (base64.length === 0 || base64.length % 4 !== 0) return null;
98 const firstPad = base64.indexOf("=");
99 if (firstPad !== -1) {
100 // Node silently truncates at the first =; allow only one or two trailing padding characters.
101 if (base64.length - firstPad > 2) return null;
102 for (let index = firstPad; index < base64.length; index += 1) {
103 if (base64.charCodeAt(index) !== 0x3d) return null;
104 }
105 }
106​
107 return { mimeType, base64 };
109​
110export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
111 const key = input.mimeType.toLowerCase();
112 const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key)
113 ? IMAGE_EXTENSION_BY_MIME_TYPE[key]
114 : undefined;
115 if (fromMime) {
116 return fromMime;
117 }
118​
119 const fromMimeExtension = Option.map(Mime.getExtension(input.mimeType), (ext) => `.${ext}`);
120 if (Option.isSome(fromMimeExtension) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension.value)) {
121 return fromMimeExtension.value;
122 }
123​
124 const fileName = input.fileName?.trim() ?? "";
125 const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
126 const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
127 if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
128 return fileNameExtension;
129 }
130​
131 return ".bin";