/** * apps/worker — MIME sniffing (magic bytes) + image dimension parsing. * * `sniffMagicBytes()` reads the FIRST 16 BYTES of a buffer and returns the * detected MIME type (or null for plain text / unknown). Used by the files * processor to verify that the declared content-type matches what is actually * uploaded (security: an uploaded ".jpg" must not silently be a PDF or zip). * * `assertMime()` enforces the match with text-family leniency (declared * text/plain vs sniffed-null is fine — plain text has no magic number). * * The dimension parsers (png/jpeg/webp) read only header bytes — no decoder * dependency. See the KNOWN GAPS in the README. */ export class MimeMismatchError extends Error { constructor( public declared: string, public actual: string | null ) { super( actual === null ? `declared MIME ${declared} but content has no recognized magic bytes` : `MIME mismatch: declared ${declared}, detected ${actual}` ); this.name = "MimeMismatchError"; } } function hex(b: number): string { return b.toString(16).toUpperCase().padStart(2, "0"); } /** * Magic-byte table (checked in order; first match wins). * * FF D8 FF image/jpeg * 89 50 4E 47 0D 0A 1A 0A image/png * 52 49 46 46 .. .. .. .. 57 45 42 50 image/webp (RIFF....WEBP) * 47 49 46 38 image/gif * 25 50 44 46 application/pdf * 50 4B 03 04 application/zip * 1A 45 DF A3 video/webm * 00 00 00 ?? 66 74 79 70 video/mp4 (ftyp box at offset 4) * FE FF / FF FE text/utf16 (BOM) * 7B 22 application/json (HEURISTIC: `{` `"` at 0:1) * 3C 3F 78 6D 6C text/xml ( (i < buffer.length ? buffer[i]! : -1); if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return "image/jpeg"; if ( b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47 && b(4) === 0x0d && b(5) === 0x0a && b(6) === 0x1a && b(7) === 0x0a ) return "image/png"; if ( b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 && b(8) === 0x57 && b(9) === 0x45 && b(10) === 0x42 && b(11) === 0x50 ) return "image/webp"; if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x38) return "image/gif"; if (b(0) === 0x25 && b(1) === 0x50 && b(2) === 0x44 && b(3) === 0x46) return "application/pdf"; if (b(0) === 0x50 && b(1) === 0x4b && b(2) === 0x03 && b(3) === 0x04) return "application/zip"; if (b(0) === 0x1a && b(1) === 0x45 && b(2) === 0xdf && b(3) === 0xa3) return "video/webm"; if ( b(0) === 0x00 && b(1) === 0x00 && b(2) === 0x00 && b(4) === 0x66 && b(5) === 0x74 && b(6) === 0x79 && b(7) === 0x70 ) return "video/mp4"; if (b(0) === 0xfe && b(1) === 0xff) return "text/utf16"; if (b(0) === 0xff && b(1) === 0xfe) return "text/utf16"; // Heuristics (documented as such): if (b(0) === 0x7b && b(1) === 0x22) return "application/json"; if (b(0) === 0x3c && b(1) === 0x3f && b(2) === 0x78 && b(3) === 0x6d && b(4) === 0x6c) return "text/xml"; if (b(0) === 0x3c && b(1) === 0x68 && b(2) === 0x74 && b(3) === 0x6d && b(4) === 0x6c) return "text/html"; return null; } function sameFamily(a: string, b: string): boolean { return a.split("/")[0] === b.split("/")[0]; } /** * Verify the declared MIME against the sniffed magic bytes. * * Rules: * - sniffed null → content looks like plain text: OK for declared text/*, * application/json or application/xml (no magic bytes expected); * otherwise throw. * - sniffed set → must equal the declared type, or share the same * major family (e.g. declared text/csv vs sniffed text/csv); declared * `application/octet-stream` is treated as "caller didn't know" and * always passes (MinIO often re-labels objects). * - throw {@link MimeMismatchError} on violation. */ export function assertMime(declared: string, actual: string | null): void { const d = declared.toLowerCase().trim(); const a = actual ? actual.toLowerCase() : null; if (a === null) { if (d.startsWith("text/") || d === "application/json" || d === "application/xml") { return; } throw new MimeMismatchError(declared, null); } if (a === d) return; if (d === "application/octet-stream") return; if (sameFamily(d, a)) return; throw new MimeMismatchError(declared, actual); } // --------------------------------------------------------------------------- // Image dimension parsing (header-only, no decoder dependency) // --------------------------------------------------------------------------- /** PNG: IHDR is the first chunk; width/height at offsets 16..24 (big-endian). */ export function pngWidthHeight(buffer: Buffer): { width: number; height: number } | null { if (buffer.length < 24) return null; if (sniffMagicBytes(buffer) !== "image/png") return null; return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } /** JPEG: walk SOFn markers (0xC0..0xCF except C4/C8/CC) → dimensions. */ export function jpegWidthHeight(buffer: Buffer): { width: number; height: number } | null { if (buffer.length < 4 || sniffMagicBytes(buffer) !== "image/jpeg") return null; let offset = 2; while (offset + 9 < buffer.length) { if (buffer[offset] !== 0xff) { offset++; continue; } // Out-of-range read → -1 (all marker comparisons below are then false, // matching the previous `undefined` arithmetic behavior). const marker = buffer[offset + 1] ?? -1; const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; const isStandAlone = (marker >= 0xd0 && marker <= 0xd9) || marker === 0x01 || marker === 0xfe; if (isStandAlone) { offset += 2; continue; } if (isSof) { // SOFn: [marker][len(2)][precision(1)][height(2)][width(2)] const height = buffer.readUInt16BE(offset + 5); const width = buffer.readUInt16BE(offset + 7); return { width, height }; } const segLen = buffer.readUInt16BE(offset + 2); offset += 2 + segLen; } return null; } /** WEBP: RIFF size + "WEBP", then VP8 / VP8L / VP8X chunk with dims. */ export function webpWidthHeight(buffer: Buffer): { width: number; height: number } | null { if (sniffMagicBytes(buffer) !== "image/webp") return null; // Layout: 0-3 "RIFF", 4-7 file size, 8-11 "WEBP", 12-15 chunk ID, 16+ payload. if (buffer.length < 16) return null; const chunk = buffer.toString("ascii", 12, 16); if (chunk === "VP8 ") { // Lossy: payload = frameTag(3) + startCode 9D 01 2A(3) + width(2 LE) + height(2 LE) if (buffer.length < 26) return null; const w = buffer.readUInt16LE(22) & 0x3fff; const h = buffer.readUInt16LE(24) & 0x3fff; return { width: w, height: h }; } if (chunk === "VP8L") { // Lossless: payload = 0x2F signature(1) + packed(4): 14-bit w-1 | 14-bit h-1 | flags if (buffer.length < 21 || buffer[16] !== 0x2f) return null; const bits = buffer.readUInt32LE(17); const w = (bits & 0x3fff) + 1; const h = ((bits >> 14) & 0x3fff) + 1; return { width: w, height: h }; } if (chunk === "VP8X") { // Extended: payload = flags(1) + reserved(3) + width-1(3 LE) + height-1(3 LE) if (buffer.length < 26) return null; const w = (buffer.readUIntLE(20, 3) & 0x00ffffff) + 1; const h = (buffer.readUIntLE(23, 3) & 0x00ffffff) + 1; return { width: w, height: h }; } return null; } /** Unified entry point: dims for any image type (null when not parseable). */ export function imageDimensions( buffer: Buffer ): { width: number; height: number } | null { const mime = sniffMagicBytes(buffer); if (mime === "image/png") return pngWidthHeight(buffer); if (mime === "image/jpeg") return jpegWidthHeight(buffer); if (mime === "image/webp") return webpWidthHeight(buffer); return null; } /** * Approximate PDF page count: count "/Type /Page" occurrences that are NOT * followed by "s" (i.e. exclude /Type /Pages). NOTE: approximate — fails on * compressed object streams (linearized / ObjStm PDFs); the files processor * records this in file metadata as an estimate. */ export function pdfPageCount(buffer: Buffer): number | null { const text = buffer.toString("latin1"); const matches = text.match(/\/Type\s*\/Page(?!\s*s)/g); return matches ? matches.length : null; }