From fbc0559a640a2e58ce6c4bb79a2409aa374d10b4 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 7 Sep 2026 12:55:11 -0400 Subject: [PATCH] feat: improve office document discovery Signed-off-by: Sebastian Krupinski --- shared/lib/Blob/Signature.php | 71 +++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/shared/lib/Blob/Signature.php b/shared/lib/Blob/Signature.php index 497e267..da03795 100644 --- a/shared/lib/Blob/Signature.php +++ b/shared/lib/Blob/Signature.php @@ -23,7 +23,10 @@ use finfo; class Signature { /** Minimum bytes needed for reliable detection */ - public const HEADER_SIZE = 256; + public const SAMPLE_SIZE = 65536; + + /** Cached finfo instance */ + private static ?finfo $finfo = null; /** * Fallback magic byte signatures for when finfo is unavailable @@ -41,16 +44,32 @@ class Signature { ['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP ]; - /** Cached finfo instance */ - private static ?finfo $finfo = null; + /** + * Zip-container marker strings used to distinguish OOXML/ODF/EPUB documents + * from a generic ZIP archive. Filenames inside a ZIP's local file headers + * (and ODF's mandatory uncompressed "mimetype" entry content) are stored + * as plain text, so a simple substring search reliably identifies these + * formats without needing to parse the archive structure. + */ + private const ZIP_CONTAINER_MARKERS = [ + 'word/document.xml' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'], + 'xl/workbook.xml' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'], + 'ppt/presentation.xml' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'pptx'], + 'application/vnd.oasis.opendocument.text' => ['application/vnd.oasis.opendocument.text', 'odt'], + 'application/vnd.oasis.opendocument.spreadsheet' => ['application/vnd.oasis.opendocument.spreadsheet', 'ods'], + 'application/vnd.oasis.opendocument.presentation' => ['application/vnd.oasis.opendocument.presentation', 'odp'], + 'application/epub+zip' => ['application/epub+zip', 'epub'], + ]; /** * Detect both MIME type and format from content bytes in a single operation * - * @param string $headerBytes First bytes of the file content (256 recommended) + * @param string $headerBytes First bytes of the file content + * @param string|null $content Full (or larger) content, when available, used to + * distinguish OOXML/ODF/EPUB documents from a generic ZIP archive * @return array{mime: string, format: string} Array with 'mime' and 'format' keys */ - public static function detect(string $headerBytes): array { + public static function detect(string $headerBytes, ?string $content = null): array { if (strlen($headerBytes) === 0) { return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; } @@ -75,6 +94,16 @@ class Signature { $format = self::detectFromMagicBytes($headerBytes); } + // A bare "zip" result is a generic container; when more of the file is + // available, check for OOXML/ODF/EPUB markers rather than reporting the + // misleadingly generic zip mime/format for what is really a document. + if ($format === 'zip' && $content !== null) { + $container = self::detectZipContainer($content); + if ($container !== null) { + [$mime, $format] = $container; + } + } + // Ensure MIME type is set if ($mime === null || $mime === MimeTypes::MIME_BINARY) { $mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY; @@ -83,6 +112,32 @@ class Signature { return ['mime' => $mime, 'format' => $format]; } + /** + * Look for known OOXML/ODF/EPUB entry markers within ZIP content + * + * Only a fixed-size window from the head and tail of the content is scanned, + * regardless of total length, so detection cost does not grow with the size + * of large archive uploads. Every entry name is mirrored in full in the ZIP + * central directory near the end of the archive, so sampling the head and + * tail is enough without scanning the whole file. + * + * @param string $data ZIP content to scan (filenames/mimetype entry are stored uncompressed) + * @return array{0: string, 1: string}|null [mime, format] pair, or null if no marker matched + */ + private static function detectZipContainer(string $data): ?array { + $length = strlen($data); + $sample = $length <= self::SAMPLE_SIZE * 2 + ? $data + : substr($data, 0, self::SAMPLE_SIZE) . substr($data, -self::SAMPLE_SIZE); + + foreach (self::ZIP_CONTAINER_MARKERS as $marker => $result) { + if (str_contains($sample, $marker)) { + return $result; + } + } + return null; + } + /** * Detect both MIME type and format from a stream in a single operation * @@ -91,7 +146,7 @@ class Signature { */ public static function detectFromStream($stream): array { $position = ftell($stream); - $headerBytes = fread($stream, self::HEADER_SIZE); + $headerBytes = fread($stream, self::SAMPLE_SIZE); fseek($stream, $position); if ($headerBytes === false || $headerBytes === '') { @@ -140,7 +195,7 @@ class Signature { */ public static function detectFormatFromStream($stream): string { $position = ftell($stream); - $headerBytes = fread($stream, self::HEADER_SIZE); + $headerBytes = fread($stream, self::SAMPLE_SIZE); fseek($stream, $position); if ($headerBytes === false || $headerBytes === '') { @@ -158,7 +213,7 @@ class Signature { */ public static function detectMimeTypeFromStream($stream): ?string { $position = ftell($stream); - $headerBytes = fread($stream, self::HEADER_SIZE); + $headerBytes = fread($stream, self::SAMPLE_SIZE); fseek($stream, $position); if ($headerBytes === false || $headerBytes === '') {