UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

557 lines (524 loc) 14.9 kB
import { Readable } from "node:stream"; export interface FileTypeResult { /** * The detected MIME type */ mimeType: string; /** * The detected file extension */ extension: string; /** * Whether the file type was verified by magic bytes */ verified: boolean; /** * The stream (potentially wrapped to allow re-reading) */ stream: Readable; } /** * Service for detecting file types and getting content types. * * @example * ```typescript * const detector = alepha.inject(FileDetector); * * // Get content type from filename * const mimeType = detector.getContentType("image.png"); // "image/png" * * // Detect file type by magic bytes * const stream = createReadStream('image.png'); * const result = await detector.detectFileType(stream, 'image.png'); * console.log(result.mimeType); // 'image/png' * console.log(result.verified); // true if magic bytes match * ``` */ export class FileDetector { /** * Magic byte signatures for common file formats. * Each signature is represented as an array of bytes or null (wildcard). */ protected static readonly MAGIC_BYTES: Record< string, { signature: (number | null)[]; mimeType: string }[] > = { // Images png: [ { signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mimeType: "image/png", }, ], jpg: [ { signature: [0xff, 0xd8, 0xff, 0xe0], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe1], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe2], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe3], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe8], mimeType: "image/jpeg" }, ], jpeg: [ { signature: [0xff, 0xd8, 0xff, 0xe0], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe1], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe2], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe3], mimeType: "image/jpeg" }, { signature: [0xff, 0xd8, 0xff, 0xe8], mimeType: "image/jpeg" }, ], gif: [ { signature: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mimeType: "image/gif", }, // GIF87a { signature: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mimeType: "image/gif", }, // GIF89a ], webp: [ { signature: [ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50, ], mimeType: "image/webp", }, ], bmp: [{ signature: [0x42, 0x4d], mimeType: "image/bmp" }], ico: [{ signature: [0x00, 0x00, 0x01, 0x00], mimeType: "image/x-icon" }], tiff: [ { signature: [0x49, 0x49, 0x2a, 0x00], mimeType: "image/tiff" }, // Little-endian { signature: [0x4d, 0x4d, 0x00, 0x2a], mimeType: "image/tiff" }, // Big-endian ], tif: [ { signature: [0x49, 0x49, 0x2a, 0x00], mimeType: "image/tiff" }, { signature: [0x4d, 0x4d, 0x00, 0x2a], mimeType: "image/tiff" }, ], // Documents pdf: [ { signature: [0x25, 0x50, 0x44, 0x46, 0x2d], mimeType: "application/pdf", }, ], // %PDF- zip: [ { signature: [0x50, 0x4b, 0x03, 0x04], mimeType: "application/zip" }, { signature: [0x50, 0x4b, 0x05, 0x06], mimeType: "application/zip" }, { signature: [0x50, 0x4b, 0x07, 0x08], mimeType: "application/zip" }, ], // Archives rar: [ { signature: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07], mimeType: "application/vnd.rar", }, ], "7z": [ { signature: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], mimeType: "application/x-7z-compressed", }, ], tar: [ { signature: [0x75, 0x73, 0x74, 0x61, 0x72], mimeType: "application/x-tar", }, ], gz: [{ signature: [0x1f, 0x8b], mimeType: "application/gzip" }], tgz: [{ signature: [0x1f, 0x8b], mimeType: "application/gzip" }], // Audio mp3: [ { signature: [0xff, 0xfb], mimeType: "audio/mpeg" }, { signature: [0xff, 0xf3], mimeType: "audio/mpeg" }, { signature: [0xff, 0xf2], mimeType: "audio/mpeg" }, { signature: [0x49, 0x44, 0x33], mimeType: "audio/mpeg" }, // ID3 ], wav: [ { signature: [ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x41, 0x56, 0x45, ], mimeType: "audio/wav", }, ], ogg: [{ signature: [0x4f, 0x67, 0x67, 0x53], mimeType: "audio/ogg" }], flac: [{ signature: [0x66, 0x4c, 0x61, 0x43], mimeType: "audio/flac" }], // fLaC // Video mp4: [ { signature: [null, null, null, null, 0x66, 0x74, 0x79, 0x70], mimeType: "video/mp4", }, // ftyp { signature: [ null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, ], mimeType: "video/mp4", }, // ftypisom { signature: [ null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32, ], mimeType: "video/mp4", }, // ftypmp42 ], webm: [{ signature: [0x1a, 0x45, 0xdf, 0xa3], mimeType: "video/webm" }], avi: [ { signature: [ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x41, 0x56, 0x49, 0x20, ], mimeType: "video/x-msvideo", }, ], mov: [ { signature: [ null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20, ], mimeType: "video/quicktime", }, ], mkv: [ { signature: [0x1a, 0x45, 0xdf, 0xa3], mimeType: "video/x-matroska" }, ], // Office (DOCX, XLSX, PPTX are all ZIP-based) docx: [ { signature: [0x50, 0x4b, 0x03, 0x04], mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }, ], xlsx: [ { signature: [0x50, 0x4b, 0x03, 0x04], mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, ], pptx: [ { signature: [0x50, 0x4b, 0x03, 0x04], mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", }, ], doc: [ { signature: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], mimeType: "application/msword", }, ], xls: [ { signature: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], mimeType: "application/vnd.ms-excel", }, ], ppt: [ { signature: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], mimeType: "application/vnd.ms-powerpoint", }, ], }; /** * All possible format signatures for checking against actual file content */ protected static readonly ALL_SIGNATURES = Object.entries( FileDetector.MAGIC_BYTES, ).flatMap(([ext, signatures]) => signatures.map((sig) => ({ ext, ...sig }))); /** * MIME type map for file extensions. * * Can be used to get the content type of file based on its extension. * Feel free to add more mime types in your project! */ public static readonly mimeMap: Record<string, string> = { // Documents json: "application/json", txt: "text/plain", html: "text/html", htm: "text/html", xml: "application/xml", csv: "text/csv", pdf: "application/pdf", md: "text/markdown", markdown: "text/markdown", rtf: "application/rtf", // Styles and scripts css: "text/css", js: "application/javascript", mjs: "application/javascript", ts: "application/typescript", jsx: "text/jsx", tsx: "text/tsx", // Archives zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed", tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip", // Images png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", tiff: "image/tiff", tif: "image/tiff", // Audio mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", // Video mp4: "video/mp4", webm: "video/webm", avi: "video/x-msvideo", mov: "video/quicktime", wmv: "video/x-ms-wmv", flv: "video/x-flv", mkv: "video/x-matroska", // Office doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", // Fonts woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", eot: "application/vnd.ms-fontobject", }; /** * Reverse MIME type map for looking up extensions from MIME types. * Prefers shorter, more common extensions when multiple exist. */ protected static readonly reverseMimeMap: Record<string, string> = (() => { const reverse: Record<string, string> = {}; // Process in order so common extensions come first for (const [ext, mimeType] of Object.entries(FileDetector.mimeMap)) { // Only set if not already set (prefer first/shorter extension) if (!reverse[mimeType]) { reverse[mimeType] = ext; } } return reverse; })(); /** * Returns the file extension for a given MIME type. * * @param mimeType - The MIME type to look up * @returns The file extension (without dot), or "bin" if not found * * @example * ```typescript * const detector = alepha.inject(FileDetector); * const ext = detector.getExtensionFromMimeType("image/png"); // "png" * const ext2 = detector.getExtensionFromMimeType("application/octet-stream"); // "bin" * ``` */ getExtensionFromMimeType(mimeType: string): string { return FileDetector.reverseMimeMap[mimeType] || "bin"; } /** * Returns the content type of file based on its filename. * * @param filename - The filename to check * @returns The MIME type * * @example * ```typescript * const detector = alepha.inject(FileDetector); * const mimeType = detector.getContentType("image.png"); // "image/png" * ``` */ getContentType(filename: string): string { const ext = filename.toLowerCase().split(".").pop() || ""; return FileDetector.mimeMap[ext] || "application/octet-stream"; } /** * Detects the file type by checking magic bytes against the stream content. * * @param stream - The readable stream to check * @param filename - The filename (used to get the extension) * @returns File type information including MIME type, extension, and verification status * * @example * ```typescript * const detector = alepha.inject(FileDetector); * const stream = createReadStream('image.png'); * const result = await detector.detectFileType(stream, 'image.png'); * console.log(result.mimeType); // 'image/png' * console.log(result.verified); // true if magic bytes match * ``` */ async detectFileType( stream: Readable, filename: string, ): Promise<FileTypeResult> { // Get the expected MIME type from the filename extension const expectedMimeType = this.getContentType(filename); // Extract extension - only if filename contains a dot const lastDotIndex = filename.lastIndexOf("."); const ext = lastDotIndex > 0 ? filename.substring(lastDotIndex + 1).toLowerCase() : ""; // Read the first 16 bytes (enough for most magic byte checks) const { buffer, stream: newStream } = await this.peekBytes(stream, 16); // First, check if the extension's expected signature matches const expectedSignatures = FileDetector.MAGIC_BYTES[ext]; if (expectedSignatures) { for (const { signature, mimeType } of expectedSignatures) { if (this.matchesSignature(buffer, signature)) { return { mimeType, extension: ext, verified: true, stream: newStream, }; } } } // If the expected signature didn't match, try all other signatures for (const { ext: detectedExt, signature, mimeType, } of FileDetector.ALL_SIGNATURES) { if (detectedExt !== ext && this.matchesSignature(buffer, signature)) { return { mimeType, extension: detectedExt, verified: true, stream: newStream, }; } } // If no magic bytes matched, fall back to extension-based detection // or return binary if extension is not recognized return { mimeType: expectedMimeType, extension: ext, verified: false, stream: newStream, }; } /** * Reads all bytes from a stream and returns the first N bytes along with a new stream containing all data. * This approach reads the entire stream upfront to avoid complex async handling issues. * * @protected */ protected async peekBytes( stream: Readable, numBytes: number, ): Promise<{ buffer: Buffer; stream: Readable }> { const chunks: Buffer[] = []; // Read the entire stream for await (const chunk of stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const allData = Buffer.concat(chunks); const buffer = allData.subarray(0, numBytes); // Create a new stream with all the data const newStream = Readable.from(allData); return { buffer, stream: newStream }; } /** * Checks if a buffer matches a magic byte signature. * * @protected */ protected matchesSignature( buffer: Buffer, signature: (number | null)[], ): boolean { if (buffer.length < signature.length) { return false; } for (let i = 0; i < signature.length; i++) { if (signature[i] !== null && buffer[i] !== signature[i]) { return false; } } return true; } }