alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
557 lines (524 loc) • 14.9 kB
text/typescript
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;
}
}