1
15
16export class MimeMismatchError extends Error {
17 constructor(
18 public declared: string,
19 public actual: string | null
20 ) {
21 super(
22 actual === null
23 ? `declared MIME ${declared} but content has no recognized magic bytes`
24 : `MIME mismatch: declared ${declared}, detected ${actual}`
25 );
26 this.name = "MimeMismatchError";
27 }
28}
29
30function hex(b: number): string {
31 return b.toString(16).toUpperCase().padStart(2, "0");
32}
33
34
55export function sniffMagicBytes(buffer: Buffer): string | null {
56 const b = (i: number): number => (i < buffer.length ? buffer[i]! : -1);
57
58 if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return "image/jpeg";
59 if (
60 b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47 &&
61 b(4) === 0x0d && b(5) === 0x0a && b(6) === 0x1a && b(7) === 0x0a
62 )
63 return "image/png";
64 if (
65 b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 &&
66 b(8) === 0x57 && b(9) === 0x45 && b(10) === 0x42 && b(11) === 0x50
67 )
68 return "image/webp";
69 if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x38)
70 return "image/gif";
71 if (b(0) === 0x25 && b(1) === 0x50 && b(2) === 0x44 && b(3) === 0x46)
72 return "application/pdf";
73 if (b(0) === 0x50 && b(1) === 0x4b && b(2) === 0x03 && b(3) === 0x04)
74 return "application/zip";
75 if (b(0) === 0x1a && b(1) === 0x45 && b(2) === 0xdf && b(3) === 0xa3)
76 return "video/webm";
77 if (
78 b(0) === 0x00 && b(1) === 0x00 && b(2) === 0x00 &&
79 b(4) === 0x66 && b(5) === 0x74 && b(6) === 0x79 && b(7) === 0x70
80 )
81 return "video/mp4";
82 if (b(0) === 0xfe && b(1) === 0xff) return "text/utf16";
83 if (b(0) === 0xff && b(1) === 0xfe) return "text/utf16";
84
85 if (b(0) === 0x7b && b(1) === 0x22) return "application/json";
86 if (b(0) === 0x3c && b(1) === 0x3f && b(2) === 0x78 && b(3) === 0x6d && b(4) === 0x6c)
87 return "text/xml";
88 if (b(0) === 0x3c && b(1) === 0x68 && b(2) === 0x74 && b(3) === 0x6d && b(4) === 0x6c)
89 return "text/html";
90 return null;
91}
92
93function sameFamily(a: string, b: string): boolean {
94 return a.split("/")[0] === b.split("/")[0];
95}
96
97
110export function assertMime(declared: string, actual: string | null): void {
111 const d = declared.toLowerCase().trim();
112 const a = actual ? actual.toLowerCase() : null;
113 if (a === null) {
114 if (d.startsWith("text/") || d === "application/json" || d === "application/xml") {
115 return;
116 }
117 throw new MimeMismatchError(declared, null);
118 }
119 if (a === d) return;
120 if (d === "application/octet-stream") return;
121 if (sameFamily(d, a)) return;
122 throw new MimeMismatchError(declared, actual);
123}
124
125
126
127
128
129
130export function pngWidthHeight(buffer: Buffer): { width: number; height: number } | null {
131 if (buffer.length < 24) return null;
132 if (sniffMagicBytes(buffer) !== "image/png") return null;
133 return {
134 width: buffer.readUInt32BE(16),
135 height: buffer.readUInt32BE(20)
136 };
137}
138
139
140export function jpegWidthHeight(buffer: Buffer): { width: number; height: number } | null {
141 if (buffer.length < 4 || sniffMagicBytes(buffer) !== "image/jpeg") return null;
142 let offset = 2;
143 while (offset + 9 < buffer.length) {
144 if (buffer[offset] !== 0xff) {
145 offset++;
146 continue;
147 }
148
149
150 const marker = buffer[offset + 1] ?? -1;
151 const isSof =
152 marker >= 0xc0 &&
153 marker <= 0xcf &&
154 marker !== 0xc4 &&
155 marker !== 0xc8 &&
156 marker !== 0xcc;
157 const isStandAlone =
158 (marker >= 0xd0 && marker <= 0xd9) || marker === 0x01 || marker === 0xfe;
159 if (isStandAlone) {
160 offset += 2;
161 continue;
162 }
163 if (isSof) {
164
165 const height = buffer.readUInt16BE(offset + 5);
166 const width = buffer.readUInt16BE(offset + 7);
167 return { width, height };
168 }
169 const segLen = buffer.readUInt16BE(offset + 2);
170 offset += 2 + segLen;
171 }
172 return null;
173}
174
175
176export function webpWidthHeight(buffer: Buffer): { width: number; height: number } | null {
177 if (sniffMagicBytes(buffer) !== "image/webp") return null;
178
179 if (buffer.length < 16) return null;
180 const chunk = buffer.toString("ascii", 12, 16);
181 if (chunk === "VP8 ") {
182
183 if (buffer.length < 26) return null;
184 const w = buffer.readUInt16LE(22) & 0x3fff;
185 const h = buffer.readUInt16LE(24) & 0x3fff;
186 return { width: w, height: h };
187 }
188 if (chunk === "VP8L") {
189
190 if (buffer.length < 21 || buffer[16] !== 0x2f) return null;
191 const bits = buffer.readUInt32LE(17);
192 const w = (bits & 0x3fff) + 1;
193 const h = ((bits >> 14) & 0x3fff) + 1;
194 return { width: w, height: h };
195 }
196 if (chunk === "VP8X") {
197
198 if (buffer.length < 26) return null;
199 const w = (buffer.readUIntLE(20, 3) & 0x00ffffff) + 1;
200 const h = (buffer.readUIntLE(23, 3) & 0x00ffffff) + 1;
201 return { width: w, height: h };
202 }
203 return null;
204}
205
206
207export function imageDimensions(
208 buffer: Buffer
209): { width: number; height: number } | null {
210 const mime = sniffMagicBytes(buffer);
211 if (mime === "image/png") return pngWidthHeight(buffer);
212 if (mime === "image/jpeg") return jpegWidthHeight(buffer);
213 if (mime === "image/webp") return webpWidthHeight(buffer);
214 return null;
215}
216
217
223export function pdfPageCount(buffer: Buffer): number | null {
224 const text = buffer.toString("latin1");
225 const matches = text.match(/\/Type\s*\/Page(?!\s*s)/g);
226 return matches ? matches.length : null;
227}
228