1
15
16
17export type MimeConfidence = "magic" | "ext" | "text" | "unknown";
18
19
20export type ZipRefinement = "docx" | "xlsx" | "pptx" | "epub";
21
22export interface MimeDetection {
23 mime: string;
24 confidence: MimeConfidence;
25
26 isZipContainer?: boolean;
27
28 refinement?: ZipRefinement;
29}
30
31export type Signature = {
32 mime: string;
33
34 bytes: readonly number[];
35
36 at?: number;
37};
38
39
44export const MIME_SIGNATURES: readonly Signature[] = [
45 { mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
46 { mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
47 { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
48 { mime: "image/webp", bytes: [0x57, 0x45, 0x42, 0x50], at: 8 },
49
50
51 { mime: "image/avif", bytes: [0x61, 0x76, 0x69, 0x66], at: 8 },
52 { mime: "image/heic", bytes: [0x6d, 0x69, 0x66, 0x31], at: 8 },
53 { mime: "audio/mp4", bytes: [0x4d, 0x34, 0x41, 0x20], at: 8 },
54 { mime: "video/quicktime", bytes: [0x71, 0x74, 0x20, 0x20], at: 8 },
55 { mime: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] },
56 { mime: "video/mp4", bytes: [0x66, 0x74, 0x79, 0x70], at: 4 },
57 { mime: "video/webm", bytes: [0x1a, 0x45, 0xdf, 0xa3] },
58 { mime: "audio/ogg", bytes: [0x4f, 0x67, 0x67, 0x53] },
59 { mime: "audio/mpeg", bytes: [0x49, 0x44, 0x33] },
60 { mime: "audio/mpeg", bytes: [0xff, 0xfb] },
61 { mime: "audio/wav", bytes: [0x57, 0x41, 0x56, 0x45], at: 8 },
62 { mime: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] },
63];
64
65
69export const EXTENSION_MIME: Record<string, string> = {
70
71 png: "image/png",
72 jpg: "image/jpeg",
73 jpeg: "image/jpeg",
74 gif: "image/gif",
75 webp: "image/webp",
76 avif: "image/avif",
77 heic: "image/heic",
78 heif: "image/heic",
79 svg: "image/svg+xml",
80 bmp: "image/bmp",
81 ico: "image/x-icon",
82 tif: "image/tiff",
83 tiff: "image/tiff",
84 tga: "image/targa",
85
86 mp4: "video/mp4",
87 m4v: "video/mp4",
88 webm: "video/webm",
89 mov: "video/quicktime",
90 avi: "video/x-msvideo",
91 mkv: "video/x-matroska",
92
93 mp3: "audio/mpeg",
94 m4a: "audio/mp4",
95 ogg: "audio/ogg",
96 oga: "audio/ogg",
97 opus: "audio/ogg",
98 wav: "audio/wav",
99 flac: "audio/flac",
100 aac: "audio/aac",
101
102 pdf: "application/pdf",
103 txt: "text/plain",
104 md: "text/markdown",
105 csv: "text/csv",
106 html: "text/html",
107 htm: "text/html",
108 xml: "application/xml",
109 json: "application/json",
110
111 zip: "application/zip",
112 gz: "application/gzip",
113 tar: "application/x-tar",
114 "7z": "application/x-7z-compressed",
115 rar: "application/vnd.rar",
116
117 doc: "application/msword",
118 docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
119 xls: "application/vnd.ms-excel",
120 xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
121 ppt: "application/vnd.ms-powerpoint",
122 pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
123 odt: "application/vnd.oasis.opendocument.text",
124 ods: "application/vnd.oasis.opendocument.spreadsheet",
125 epub: "application/epub+zip",
126 rtf: "application/rtf",
127
128 js: "text/javascript",
129 mjs: "text/javascript",
130 css: "text/css",
131 wasm: "application/wasm",
132 apk: "application/vnd.android.package-archive",
133 exe: "application/x-msdownload",
134 dll: "application/vnd.microsoft.dll",
135 bat: "application/x-dosexec",
136 msi: "application/x-msi-installer",
137 bin: "application/octet-stream",
138};
139
140
141export const MIME_EXTENSION: Record<string, string> = (() => {
142 const out: Record<string, string> = {};
143 for (const [ext, mime] of Object.entries(EXTENSION_MIME)) {
144 if (!(mime in out)) out[mime] = ext;
145 }
146 return out;
147})();
148
149
150export const TEXT_SCAN_BYTES = 8000;
151
152function bytesAt(buf: Uint8Array, bytes: readonly number[], at: number): boolean {
153 if (at + bytes.length > buf.length) return false;
154 for (let i = 0; i < bytes.length; i++) {
155 if (buf[at + i] !== bytes[i]) return false;
156 }
157 return true;
158}
159
160function extensionOf(filename: string): string {
161 const clean = filename.split(/[\\/]/).pop() ?? filename;
162 const dot = clean.lastIndexOf(".");
163 if (dot <= 0 || dot === clean.length - 1) return "";
164 return clean.slice(dot + 1).toLowerCase();
165}
166
167
172function refineZip(buf: Uint8Array): { mime: string; refinement: ZipRefinement } | null {
173 const limit = Math.min(buf.length, 1_000_000);
174 let pos = 0;
175 for (let i = 0; i < 50 && pos + 30 <= limit; i++) {
176 if (
177 buf[pos] !== 0x50 ||
178 buf[pos + 1] !== 0x4b ||
179 buf[pos + 2] !== 0x03 ||
180 buf[pos + 3] !== 0x04
181 ) {
182 break;
183 }
184 const nameLen = buf[pos + 26]! | (buf[pos + 27]! << 8);
185 const extraLen = buf[pos + 28]! | (buf[pos + 29]! << 8);
186 const nameStart = pos + 30;
187 if (nameStart + nameLen > limit) break;
188 const name = new TextDecoder("utf-8", { fatal: false }).decode(
189 buf.subarray(nameStart, nameStart + nameLen),
190 );
191 if (name.startsWith("word/")) {
192 return {
193 mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
194 refinement: "docx",
195 };
196 }
197 if (name.startsWith("xl/")) {
198 return {
199 mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
200 refinement: "xlsx",
201 };
202 }
203 if (name.startsWith("ppt/")) {
204 return {
205 mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
206 refinement: "pptx",
207 };
208 }
209 if (name.startsWith("META-INF/")) {
210 return { mime: "application/epub+zip", refinement: "epub" };
211 }
212 pos = nameStart + nameLen + extraLen;
213
214
215 }
216 return null;
217}
218
219
225export function detectMimeType(buf: Uint8Array, filenameHint?: string): MimeDetection {
226 for (const sig of MIME_SIGNATURES) {
227 const at = sig.at ?? 0;
228 if (bytesAt(buf, sig.bytes, at)) {
229 if (sig.mime === "application/zip") {
230 const refined = refineZip(buf);
231 return {
232 mime: refined?.mime ?? "application/zip",
233 confidence: "magic",
234 isZipContainer: true,
235 ...(refined ? { refinement: refined.refinement } : {}),
236 };
237 }
238 return { mime: sig.mime, confidence: "magic" };
239 }
240 }
241
242 if (filenameHint) {
243 const ext = extensionOf(filenameHint);
244 const byExt = ext !== "" ? EXTENSION_MIME[ext] : undefined;
245 if (byExt) return { mime: byExt, confidence: "ext" };
246 }
247
248
249 const scan = buf.length <= TEXT_SCAN_BYTES ? buf : buf.subarray(0, TEXT_SCAN_BYTES);
250 let isText = scan.length > 0;
251 for (let i = 0; i < scan.length; i++) {
252 if (scan[i] === 0) {
253 isText = false;
254 break;
255 }
256 }
257 if (isText) return { mime: "text/plain", confidence: "text" };
258
259 return { mime: "application/octet-stream", confidence: "unknown" };
260}
261
262const IMAGE_MIMES = new Set([
263 "image/png",
264 "image/jpeg",
265 "image/gif",
266 "image/webp",
267 "image/avif",
268 "image/heic",
269 "image/svg+xml",
270 "image/bmp",
271 "image/x-icon",
272]);
273
274const VIDEO_MIMES = new Set([
275 "video/mp4",
276 "video/webm",
277 "video/quicktime",
278 "video/x-msvideo",
279 "video/x-matroska",
280 "video/ogg",
281]);
282
283const AUDIO_MIMES = new Set([
284 "audio/mpeg",
285 "audio/mp4",
286 "audio/ogg",
287 "audio/wav",
288 "audio/flac",
289 "audio/aac",
290]);
291
292const DOCUMENT_MIMES = new Set([
293 "application/pdf",
294 "application/msword",
295 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
296 "application/vnd.ms-excel",
297 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
298 "application/vnd.ms-powerpoint",
299 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
300 "application/vnd.oasis.opendocument.text",
301 "application/vnd.oasis.opendocument.spreadsheet",
302 "application/epub+zip",
303 "application/rtf",
304 "text/plain",
305 "text/markdown",
306 "text/csv",
307 "text/html",
308 "text/javascript",
309 "text/css",
310 "application/json",
311 "application/xml",
312]);
313
314export function isImageMime(mime: string): boolean {
315 return IMAGE_MIMES.has(mime);
316}
317
318export function isVideoMime(mime: string): boolean {
319 return VIDEO_MIMES.has(mime);
320}
321
322export function isAudioMime(mime: string): boolean {
323 return AUDIO_MIMES.has(mime);
324}
325
326export function isDocumentMime(mime: string): boolean {
327 return DOCUMENT_MIMES.has(mime);
328}
329
330
331const BLOCKED_MIMES = new Set([
332 "application/x-msdownload",
333 "application/vnd.microsoft.dll",
334 "application/x-msdos-program",
335 "application/x-executable",
336 "application/x-sharedlib",
337 "application/vnd.microsoft.portable-executable",
338 "application/x-dosexec",
339]);
340
341
342const BLOCKED_EXTENSIONS = new Set(["exe", "dll", "bat", "sh", "app", "msi", "com", "scr"]);
343
344
345export interface AllowedUploadOptions {
346
347 maxBytes?: number;
348
349 size?: number;
350
351 filename?: string;
352}
353
354export interface UploadPolicyResult {
355 allowed: boolean;
356
357 reason?: "executable" | "too_large";
358}
359
360
370export function isAllowedUpload(mime: string, opts: AllowedUploadOptions = {}): UploadPolicyResult {
371 const maxBytes = opts.maxBytes ?? 500 * 1024 * 1024;
372 const ext = opts.filename !== undefined ? extensionOf(opts.filename) : "";
373 if (BLOCKED_MIMES.has(mime) || BLOCKED_EXTENSIONS.has(ext)) {
374 return { allowed: false, reason: "executable" };
375 }
376 if (opts.size !== undefined && opts.size > maxBytes) {
377 return { allowed: false, reason: "too_large" };
378 }
379 return { allowed: true };
380}
381
382
383
384
396export const UPLOAD_ALLOWED_MIMES: ReadonlySet<string> = new Set([
397
398 "image/png",
399 "image/jpeg",
400 "image/webp",
401 "image/gif",
402 "image/svg+xml",
403
404 "application/pdf",
405 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
406 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
407 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
408 "text/markdown",
409 "text/plain",
410 "text/csv",
411
412 "application/zip",
413
414 "video/mp4",
415 "video/webm",
416 "video/quicktime",
417
418 "audio/mpeg",
419 "audio/wav",
420 "audio/mp4",
421
422 "application/octet-stream",
423]);
424
425
429export function isAllowedMime(mime: string): boolean {
430 return UPLOAD_ALLOWED_MIMES.has(mime);
431}
432
433
439export const MIME_SNIFF_TABLE: readonly Signature[] = MIME_SIGNATURES;
440
441
450export const CONTENT_TYPE_OVERRIDES: Record<string, { contentType: string; disposition: "inline" | "attachment" }> =
451 {
452 "image/svg+xml": { contentType: "text/plain", disposition: "attachment" },
453 };
454
455
465export function sniffMagicBytes(buffer: Uint8Array): string | null {
466 for (const sig of MIME_SIGNATURES) {
467 if (bytesAt(buffer, sig.bytes, sig.at ?? 0)) return sig.mime;
468 }
469 return null;
470}
471
472
491export type SniffVerdict = "ok" | "no-signal" | "mismatch";
492
493
497const SNIFF_FAMILY_ALIASES: Record<string, string[]> = {
498 "video/quicktime": ["video/mp4"],
499 "audio/mp4": ["video/mp4"],
500};
501
502export function sniffVerdict(buffer: Uint8Array, claimed: string): SniffVerdict {
503 const claimHasSignature = MIME_SIGNATURES.some((s) => s.mime === claimed);
504 if (!claimHasSignature) return "no-signal";
505 const sniffed = sniffMagicBytes(buffer);
506 if (sniffed === null) return "mismatch";
507 if (sniffed === claimed) return "ok";
508 if ((SNIFF_FAMILY_ALIASES[sniffed] ?? []).includes(claimed)) return "ok";
509 return "mismatch";
510}
511