Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.44% |
76 / 78 |
|
50.00% |
1 / 2 |
CRAP | |
0.00% |
0 / 1 |
| ImageMimeTypeSniffer | |
97.44% |
76 / 78 |
|
50.00% |
1 / 2 |
31 | |
0.00% |
0 / 1 |
| checkFilePathIsRegularFile | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
9 | |||
| detect | |
96.77% |
60 / 62 |
|
0.00% |
0 / 1 |
22 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace ImageMimeTypeSniffer; |
| 4 | |
| 5 | class ImageMimeTypeSniffer |
| 6 | { |
| 7 | |
| 8 | private static function checkFilePathIsRegularFile($input) |
| 9 | { |
| 10 | if (gettype($input) !== 'string') { |
| 11 | throw new \Exception('File path must be string'); |
| 12 | } |
| 13 | if (strpos($input, chr(0)) !== false) { |
| 14 | throw new \Exception('NUL character is not allowed in file path!'); |
| 15 | } |
| 16 | if (preg_match('#[\x{0}-\x{1f}]#', $input)) { |
| 17 | // prevents line feed, new line, tab, charater return, tab, ets. |
| 18 | throw new \Exception('Control characters #0-#20 not allowed in file path!'); |
| 19 | } |
| 20 | // Prevent phar stream wrappers (security threat) |
| 21 | if (preg_match('#^phar://#', $input)) { |
| 22 | throw new \Exception('phar stream wrappers are not allowed in file path'); |
| 23 | } |
| 24 | if (preg_match('#^(php|glob)://#', $input)) { |
| 25 | throw new \Exception('php and glob stream wrappers are not allowed in file path'); |
| 26 | } |
| 27 | if (empty($input)) { |
| 28 | throw new \Exception('File path is empty!'); |
| 29 | } |
| 30 | if (!@file_exists($input)) { |
| 31 | throw new \Exception('File does not exist'); |
| 32 | } |
| 33 | if (@is_dir($input)) { |
| 34 | throw new \Exception('Expected a regular file, not a dir'); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | |
| 39 | /** |
| 40 | * Try to detect mime type by sniffing the signature |
| 41 | * |
| 42 | * Returns: |
| 43 | * - mime type (string) (if it is in fact an image, and type could be determined) |
| 44 | * - null (if nothing can be determined) |
| 45 | * |
| 46 | * @param string $filePath The path to the file |
| 47 | * @return string|null mimetype (if it is an image, and type could be determined), |
| 48 | * or null (if the file does not match any of the signatures tested) |
| 49 | * @throws \Exception if file cannot be opened/read |
| 50 | */ |
| 51 | public static function detect($filePath) |
| 52 | { |
| 53 | self::checkFilePathIsRegularFile($filePath); |
| 54 | |
| 55 | |
| 56 | $handle = @fopen($filePath, 'r'); |
| 57 | if ($handle === false) { |
| 58 | throw new \Exception('File could not be opened'); |
| 59 | } |
| 60 | // 20 bytes is sufficient for all our sniffers, except image/svg+xml. |
| 61 | // The svg sniffer takes care of reading more |
| 62 | $sampleBin = @fread($handle, 20); |
| 63 | if ($sampleBin === false) { |
| 64 | throw new \Exception('File could not be read'); |
| 65 | } |
| 66 | if (strlen($sampleBin) < 20) { |
| 67 | return null; // File is too small for us to deal with |
| 68 | } |
| 69 | $firstByte = $sampleBin[0]; |
| 70 | $sampleHex = strtoupper(bin2hex($sampleBin)); |
| 71 | |
| 72 | $hexPatterns = []; |
| 73 | $binPatterns = []; |
| 74 | |
| 75 | //$hexPatterns[] = ['image/heic', "/667479706865(6963|6978|7663|696D|6973|766D|7673)/"]; |
| 76 | //$hexPatterns[] = ['image/heif', "/667479706D(69|73)6631)/"]; |
| 77 | // heic: |
| 78 | |
| 79 | // HEIC signature: https://github.com/strukturag/libheif/issues/83#issuecomment-421427091 |
| 80 | // https://nokiatech.github.io/heif/technical.html |
| 81 | // https://perkeep.org/internal/magic/magic.go |
| 82 | // https://www.file-recovery.com/mp4-signature-format.htm |
| 83 | $binPatterns[] = ['image/heic', "/^(.{4}|.{8})ftyphe(ic|ix|vc|im|is|vm|vs)/"]; |
| 84 | //$binPatterns[] = ['image/heif', "/^(.{4}|.{8})ftypm(i|s)f1/"]; |
| 85 | |
| 86 | // https://www.rapidtables.com/convert/number/hex-to-ascii.html |
| 87 | switch ($firstByte) { |
| 88 | case "\x00": |
| 89 | $hexPatterns[] = ['image/x-icon', "/^00000(1?2)00/"]; |
| 90 | |
| 91 | $binPatterns[] = ['image/avif', "/^(.{4}|.{8})ftypavif/"]; |
| 92 | |
| 93 | if (preg_match("/^.{8}6A502020/", $sampleHex) === 1) { |
| 94 | // jpeg-2000 - a bit more complex, as block size may vary |
| 95 | // https://www.file-recovery.com/jp2-signature-format.htm |
| 96 | $block1Size = hexdec("0x" . substr($sampleHex, 0, 8)); |
| 97 | |
| 98 | $moreBytes = @fread($handle, $block1Size + 4 + 8); |
| 99 | if ($moreBytes !== false) { |
| 100 | $sampleBin .= $moreBytes; |
| 101 | } |
| 102 | if (substr($sampleBin, $block1Size + 4, 4) == 'ftyp') { |
| 103 | $subtyp = substr($sampleBin, $block1Size + 8, 4); |
| 104 | // "jp2 " (.JP2), "jp20" (.JPA), "jpm " (.JPM), "jpx " (.JPX). |
| 105 | |
| 106 | if ($subtyp == 'mjp2') { |
| 107 | return 'video/mj2'; |
| 108 | } else { |
| 109 | return 'image/' . rtrim($subtyp); |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | break; |
| 115 | |
| 116 | case "8": |
| 117 | $binPatterns[] = ['application/psd', "/^8BPS/"]; |
| 118 | break; |
| 119 | |
| 120 | case "B": |
| 121 | $binPatterns[] = ['image/bmp', "/^BM/"]; |
| 122 | break; |
| 123 | |
| 124 | case "G": |
| 125 | $binPatterns[] = ['image/gif', "/^GIF8(7|9)a/"]; |
| 126 | break; |
| 127 | |
| 128 | case "I": |
| 129 | $hexPatterns[] = ['image/tiff', "/^(49492A00|4D4D002A)/"]; |
| 130 | break; |
| 131 | |
| 132 | case "R": |
| 133 | // PS: Another library is more specific: /^RIFF.{4}WEBPVP/ |
| 134 | // Is "VP" always there? |
| 135 | $binPatterns[] = ['image/webp', "/^RIFF.{4}WEBP/"]; |
| 136 | break; |
| 137 | |
| 138 | case "<": |
| 139 | // Another library looks for end bracket for svg. |
| 140 | // We do not, as it requires more bytes read. |
| 141 | // Note that <xml> tag might be big too... - so we read in 200 extra |
| 142 | $moreBytes = @fread($handle, 200); |
| 143 | if ($moreBytes !== false) { |
| 144 | $sampleBin .= $moreBytes; |
| 145 | } |
| 146 | $binPatterns[] = ['image/svg+xml', "/^(<\?xml[^>]*\?>.*)?<svg/is"]; |
| 147 | break; |
| 148 | |
| 149 | case "\x89": |
| 150 | $hexPatterns[] = ['image/png', "/^89504E470D0A1A0A/"]; |
| 151 | break; |
| 152 | |
| 153 | case "\xFF": |
| 154 | $hexPatterns[] = ['image/jpeg', "/^FFD8FF(DB|E0|EE|E1)/"]; |
| 155 | break; |
| 156 | } |
| 157 | |
| 158 | foreach ($hexPatterns as list($mime, $pattern)) { |
| 159 | if (preg_match($pattern, $sampleHex) === 1) { |
| 160 | return $mime; |
| 161 | } |
| 162 | } |
| 163 | foreach ($binPatterns as list($mime, $pattern)) { |
| 164 | if (preg_match($pattern, $sampleBin) === 1) { |
| 165 | return $mime; |
| 166 | } |
| 167 | } |
| 168 | return null; |
| 169 | |
| 170 | /* |
| 171 | https://en.wikipedia.org/wiki/List_of_file_signatures |
| 172 | https://github.com/zjsxwc/mime-type-sniffer/blob/master/src/MimeTypeSniffer/MimeTypeSniffer.php |
| 173 | http://phil.lavin.me.uk/2011/12/php-accurately-detecting-the-type-of-a-file/ |
| 174 | |
| 175 | */ |
| 176 | } |
| 177 | } |