exif.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <?php
  2. /**
  3. * Metadata stripping for uploaded images — the per-gallery "strip EXIF" option.
  4. *
  5. * Why the server and not the browser
  6. * ----------------------------------
  7. * Thumbnails and the resolution cap are browser work, because both need the
  8. * decoded pixels anyway. Stripping needs none: it is a container rewrite, the
  9. * uploaded file already sits in a temp file on the webhost, and doing it here
  10. * means the promise "this gallery carries no EXIF" holds for every upload —
  11. * including one from a stale cached admin.js or a hand-crafted POST. A gallery
  12. * with a resolution cap gets stripping for free from the browser's re-encode;
  13. * this is what makes it available to galleries that keep their originals.
  14. *
  15. * What is removed
  16. * ---------------
  17. * Everything a camera, phone or editor writes about the photo — EXIF (camera,
  18. * lens, exposure, timestamps, GPS), XMP, IPTC/Photoshop blocks, comments — but
  19. * nothing the picture needs to render:
  20. *
  21. * - the pixels are never touched: no decode, no re-encode, no quality loss
  22. * - the ICC colour profile stays, or colours would shift
  23. * - the JFIF (density) and Adobe (colour transform) blocks stay
  24. * - the orientation flag is re-written on its own, so a photo shot in
  25. * portrait still shows upright. It says which way up, not who or where.
  26. *
  27. * JPEG, PNG and WebP are understood. Anything else (RAW, AVIF, video, a file
  28. * the parser does not recognise) is left alone and uploaded as it arrived —
  29. * best-effort by nature, exactly like the resolution cap. Every entry point
  30. * verifies the result with getimagesize() before it is used, so a parse that
  31. * goes wrong costs the strip, never the photo.
  32. */
  33. declare(strict_types=1);
  34. /** Metadata segments/chunks bigger than this are skipped rather than examined. */
  35. const EXIF_MAX_PARSE_BYTES = 1024 * 1024;
  36. /**
  37. * Write a metadata-free copy of $src to a temp file and return its path — the
  38. * caller owns that file and must unlink it. Returns null when the format is not
  39. * understood, when there was nothing to strip, or when the rewrite produced
  40. * anything other than the same image at the same size; in every one of those
  41. * cases the caller should simply use the original.
  42. */
  43. function exif_strip_copy(string $src): ?string
  44. {
  45. $in = @fopen($src, 'rb');
  46. if ($in === false) {
  47. return null;
  48. }
  49. $format = exif_detect_format((string)fread($in, 12));
  50. if ($format === null) {
  51. fclose($in);
  52. return null;
  53. }
  54. rewind($in);
  55. $dest = @tempnam(sys_get_temp_dir(), 'fpexif');
  56. $out = $dest !== false ? @fopen($dest, 'wb') : false;
  57. if ($dest === false || $out === false) {
  58. fclose($in);
  59. if ($dest !== false) {
  60. @unlink($dest);
  61. }
  62. return null;
  63. }
  64. try {
  65. $removed = match ($format) {
  66. 'jpeg' => exif_strip_jpeg($in, $out),
  67. 'png' => exif_strip_png($in, $out),
  68. 'webp' => exif_strip_webp($in, $out),
  69. };
  70. } catch (Throwable $e) {
  71. $removed = false;
  72. }
  73. fclose($in);
  74. fclose($out);
  75. // A rewrite that removed nothing is a byte-for-byte copy: drop it and let
  76. // the original go up, saving a second read of the whole file.
  77. if (!$removed || !exif_same_image($src, $dest)) {
  78. @unlink($dest);
  79. return null;
  80. }
  81. return $dest;
  82. }
  83. /** 'jpeg' | 'png' | 'webp' from the first bytes of a file, or null. */
  84. function exif_detect_format(string $head): ?string
  85. {
  86. if (str_starts_with($head, "\xFF\xD8\xFF")) {
  87. return 'jpeg';
  88. }
  89. if (str_starts_with($head, "\x89PNG\r\n\x1A\n")) {
  90. return 'png';
  91. }
  92. if (str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP') {
  93. return 'webp';
  94. }
  95. return null;
  96. }
  97. /**
  98. * The safety net: the stripped file must still be the same image. Anything the
  99. * parser got wrong — a truncated copy, a segment length misread, a container we
  100. * only thought we understood — shows up here as a failed or differing
  101. * getimagesize(), and the stripped copy is thrown away.
  102. */
  103. function exif_same_image(string $src, string $dest): bool
  104. {
  105. $a = @getimagesize($src);
  106. $b = @getimagesize($dest);
  107. return is_array($a) && is_array($b)
  108. && $a[0] === $b[0] && $a[1] === $b[1] && $a[2] === $b[2];
  109. }
  110. // ---------------------------------------------------------------------------
  111. // TIFF (the block inside an EXIF segment)
  112. // ---------------------------------------------------------------------------
  113. /**
  114. * The Orientation tag (0x0112) of a TIFF/EXIF block, or 1 ("upright") when it
  115. * is absent or unreadable. Only IFD0 is walked: orientation lives there, and a
  116. * block this code cannot follow simply reads as upright — the same thing a
  117. * viewer does with a missing tag.
  118. */
  119. function exif_tiff_orientation(string $tiff): int
  120. {
  121. if (strlen($tiff) < 8) {
  122. return 1;
  123. }
  124. // Byte order is declared by the block itself: 'II' little-endian, 'MM' big.
  125. $order = substr($tiff, 0, 2);
  126. if ($order === 'II') {
  127. [$short, $long] = ['v', 'V'];
  128. } elseif ($order === 'MM') {
  129. [$short, $long] = ['n', 'N'];
  130. } else {
  131. return 1;
  132. }
  133. if (unpack($short, substr($tiff, 2, 2))[1] !== 42) {
  134. return 1;
  135. }
  136. $ifd = unpack($long, substr($tiff, 4, 4))[1];
  137. if ($ifd < 8 || $ifd + 2 > strlen($tiff)) {
  138. return 1;
  139. }
  140. $count = unpack($short, substr($tiff, $ifd, 2))[1];
  141. for ($i = 0; $i < $count; $i++) {
  142. $entry = $ifd + 2 + $i * 12;
  143. if ($entry + 12 > strlen($tiff)) {
  144. break;
  145. }
  146. if (unpack($short, substr($tiff, $entry, 2))[1] !== 0x0112) {
  147. continue;
  148. }
  149. // Type 3 = SHORT, and a single one fits in the entry's value field.
  150. if (unpack($short, substr($tiff, $entry + 2, 2))[1] !== 3) {
  151. break;
  152. }
  153. $value = unpack($short, substr($tiff, $entry + 8, 2))[1];
  154. return $value >= 1 && $value <= 8 ? $value : 1;
  155. }
  156. return 1;
  157. }
  158. /**
  159. * A complete TIFF block holding one tag: Orientation. 26 bytes, big-endian,
  160. * one IFD, no thumbnail, no maker note — the whole point being that this is
  161. * everything we are willing to keep.
  162. */
  163. function exif_minimal_tiff(int $orientation): string
  164. {
  165. return "MM\x00\x2A" . pack('N', 8) // header, IFD0 starts at byte 8
  166. . pack('n', 1) // one entry
  167. . pack('n', 0x0112) . pack('n', 3) . pack('N', 1)
  168. . pack('n', $orientation) . "\x00\x00" // SHORT, left-aligned in 4 bytes
  169. . pack('N', 0); // no IFD1
  170. }
  171. // ---------------------------------------------------------------------------
  172. // Stream helpers
  173. // ---------------------------------------------------------------------------
  174. /** Exactly $len bytes, or null if the stream ended early. */
  175. function exif_read_exact($fh, int $len): ?string
  176. {
  177. $buf = '';
  178. while (strlen($buf) < $len) {
  179. $chunk = fread($fh, $len - strlen($buf));
  180. if ($chunk === false || $chunk === '') {
  181. return null;
  182. }
  183. $buf .= $chunk;
  184. }
  185. return $buf;
  186. }
  187. /** Copy $len bytes across without holding them in memory. */
  188. function exif_copy_bytes($in, $out, int $len): bool
  189. {
  190. return $len === 0 || stream_copy_to_stream($in, $out, $len) === $len;
  191. }
  192. // ---------------------------------------------------------------------------
  193. // JPEG
  194. // ---------------------------------------------------------------------------
  195. /**
  196. * JPEG is a chain of marker segments (0xFF, marker, 2-byte length, payload)
  197. * ending at the start-of-scan, after which the entropy-coded image data runs to
  198. * the end of the file. Metadata lives entirely in the segments, so stripping is
  199. * a copy that skips some of them and never looks at the scan.
  200. */
  201. function exif_strip_jpeg($in, $out): bool
  202. {
  203. if (fread($in, 2) !== "\xFF\xD8") {
  204. return false;
  205. }
  206. fwrite($out, "\xFF\xD8");
  207. $removed = false;
  208. while (true) {
  209. $head = exif_read_exact($in, 2);
  210. if ($head === null || $head[0] !== "\xFF") {
  211. return false;
  212. }
  213. $marker = ord($head[1]);
  214. // Start of scan: the rest of the file is image data, copied verbatim.
  215. if ($marker === 0xDA) {
  216. fwrite($out, $head);
  217. return stream_copy_to_stream($in, $out) !== false && $removed;
  218. }
  219. // Markers that carry no payload (only 0x01 and the restart markers can
  220. // legally appear out here, but passing any of them through keeps a file
  221. // with padding between segments intact).
  222. if ($marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD9)) {
  223. fwrite($out, $head);
  224. continue;
  225. }
  226. $lenBytes = exif_read_exact($in, 2);
  227. if ($lenBytes === null) {
  228. return false;
  229. }
  230. $len = unpack('n', $lenBytes)[1];
  231. if ($len < 2) {
  232. return false;
  233. }
  234. $payloadLen = $len - 2;
  235. // Only APPn and COM can hold metadata; everything else (quantisation
  236. // tables, Huffman tables, frame headers) is structure and streams past.
  237. $isApp = $marker >= 0xE0 && $marker <= 0xEF;
  238. if (!$isApp && $marker !== 0xFE) {
  239. fwrite($out, $head . $lenBytes);
  240. if (!exif_copy_bytes($in, $out, $payloadLen)) {
  241. return false;
  242. }
  243. continue;
  244. }
  245. // An APP segment is at most 64 KB, so reading it whole is cheap — and
  246. // the decision needs its first bytes anyway.
  247. $payload = exif_read_exact($in, $payloadLen);
  248. if ($payload === null) {
  249. return false;
  250. }
  251. if (exif_jpeg_segment_is_structural($marker, $payload)) {
  252. fwrite($out, $head . $lenBytes . $payload);
  253. continue;
  254. }
  255. $removed = true;
  256. // The one thing worth rescuing: how the camera was held. Re-emitted as
  257. // a segment holding that tag and nothing else, in place of the original.
  258. if ($marker === 0xE1 && str_starts_with($payload, "Exif\x00\x00")) {
  259. $orientation = exif_tiff_orientation(substr($payload, 6));
  260. if ($orientation > 1) {
  261. $slim = "Exif\x00\x00" . exif_minimal_tiff($orientation);
  262. fwrite($out, "\xFF\xE1" . pack('n', strlen($slim) + 2) . $slim);
  263. }
  264. }
  265. }
  266. }
  267. /**
  268. * True for the few APP segments that describe how to render the image rather
  269. * than where it came from. Everything else — EXIF and XMP (APP1), IPTC and the
  270. * Photoshop resource block (APP13), FlashPix, vendor blocks, comments — goes.
  271. */
  272. function exif_jpeg_segment_is_structural(int $marker, string $payload): bool
  273. {
  274. return match ($marker) {
  275. 0xE0 => true, // JFIF: pixel density
  276. 0xE2 => str_starts_with($payload, "ICC_PROFILE\x00"), // colour profile
  277. 0xEE => str_starts_with($payload, 'Adobe'), // colour transform
  278. default => false,
  279. };
  280. }
  281. // ---------------------------------------------------------------------------
  282. // PNG
  283. // ---------------------------------------------------------------------------
  284. /** Chunks that hold metadata rather than image data. */
  285. const EXIF_PNG_DROP_CHUNKS = ['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME'];
  286. /**
  287. * PNG is a signature followed by length/type/data/CRC chunks. Dropping one is
  288. * simply not copying it; because every chunk carries its own CRC, nothing has
  289. * to be recomputed for the chunks that stay.
  290. */
  291. function exif_strip_png($in, $out): bool
  292. {
  293. $sig = exif_read_exact($in, 8);
  294. if ($sig === null) {
  295. return false;
  296. }
  297. fwrite($out, $sig);
  298. $removed = false;
  299. while (true) {
  300. $head = exif_read_exact($in, 8);
  301. if ($head === null) {
  302. return false; // ran out before IEND
  303. }
  304. $len = unpack('N', substr($head, 0, 4))[1];
  305. $type = substr($head, 4, 4);
  306. if (in_array($type, EXIF_PNG_DROP_CHUNKS, true)) {
  307. // Only eXIf is worth reading (for the orientation); the rest is
  308. // skipped without ever being held in memory.
  309. $data = null;
  310. if ($type === 'eXIf' && $len <= EXIF_MAX_PARSE_BYTES) {
  311. $data = exif_read_exact($in, $len);
  312. if ($data === null) {
  313. return false;
  314. }
  315. } elseif (fseek($in, $len, SEEK_CUR) !== 0) {
  316. return false;
  317. }
  318. fseek($in, 4, SEEK_CUR); // the chunk's CRC
  319. $removed = true;
  320. if ($data !== null && ($orientation = exif_tiff_orientation($data)) > 1) {
  321. fwrite($out, exif_png_chunk('eXIf', exif_minimal_tiff($orientation)));
  322. }
  323. continue;
  324. }
  325. fwrite($out, $head);
  326. if (!exif_copy_bytes($in, $out, $len)) {
  327. return false;
  328. }
  329. $crc = exif_read_exact($in, 4);
  330. if ($crc === null) {
  331. return false;
  332. }
  333. fwrite($out, $crc);
  334. // IEND closes the image; anything appended after it is not part of the
  335. // PNG and is deliberately not carried over.
  336. if ($type === 'IEND') {
  337. return $removed;
  338. }
  339. }
  340. }
  341. /** One PNG chunk, CRC included (PHP's crc32 is the one PNG specifies). */
  342. function exif_png_chunk(string $type, string $data): string
  343. {
  344. return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data));
  345. }
  346. // ---------------------------------------------------------------------------
  347. // WebP
  348. // ---------------------------------------------------------------------------
  349. /**
  350. * WebP is RIFF: a 12-byte header whose size field covers everything after it,
  351. * then FourCC/size/payload chunks padded to an even length. Metadata sits in
  352. * the 'EXIF' and 'XMP ' chunks, and an extended file announces their presence
  353. * in the VP8X flag byte — so dropping them means clearing those bits too, or
  354. * decoders go looking for chunks that are no longer there.
  355. *
  356. * The orientation is read in a first pass, because VP8X (start of file) has to
  357. * be written before the EXIF chunk (end of file) is reached.
  358. */
  359. function exif_strip_webp($in, $out): bool
  360. {
  361. $header = exif_read_exact($in, 12);
  362. if ($header === null) {
  363. return false;
  364. }
  365. $orientation = exif_webp_orientation($in);
  366. fwrite($out, $header); // RIFF size is patched in at the end
  367. $payloadBytes = 4; // the 'WEBP' FourCC already written
  368. $removed = false;
  369. while (!feof($in)) {
  370. $head = exif_read_exact($in, 8);
  371. if ($head === null) {
  372. break; // clean end of file
  373. }
  374. $type = substr($head, 0, 4);
  375. $len = unpack('V', substr($head, 4, 4))[1];
  376. $padded = $len + ($len % 2);
  377. if ($type === 'EXIF' || $type === 'XMP ') {
  378. if (fseek($in, $padded, SEEK_CUR) !== 0) {
  379. return false;
  380. }
  381. $removed = true;
  382. if ($type === 'EXIF' && $orientation > 1) {
  383. $slim = exif_minimal_tiff($orientation);
  384. fwrite($out, 'EXIF' . pack('V', strlen($slim)) . $slim);
  385. $payloadBytes += 8 + strlen($slim);
  386. }
  387. continue;
  388. }
  389. if ($type === 'VP8X' && $len >= 10) {
  390. $data = exif_read_exact($in, $padded);
  391. if ($data === null) {
  392. return false;
  393. }
  394. // Bit 3 = EXIF present, bit 2 = XMP present. The EXIF bit survives
  395. // only when an orientation-only chunk is being written back.
  396. $flags = ord($data[0]) & ~0x0C;
  397. if ($orientation > 1) {
  398. $flags |= 0x08;
  399. }
  400. $data[0] = chr($flags);
  401. fwrite($out, $head . $data);
  402. $payloadBytes += 8 + $padded;
  403. continue;
  404. }
  405. fwrite($out, $head);
  406. if (!exif_copy_bytes($in, $out, $padded)) {
  407. return false;
  408. }
  409. $payloadBytes += 8 + $padded;
  410. }
  411. // RIFF states its own length, which just changed.
  412. if (fseek($out, 4) !== 0) {
  413. return false;
  414. }
  415. fwrite($out, pack('V', $payloadBytes));
  416. return $removed;
  417. }
  418. /** Orientation from a WebP's EXIF chunk, leaving $in rewound for the real pass. */
  419. function exif_webp_orientation($in): int
  420. {
  421. $start = ftell($in);
  422. $orientation = 1;
  423. while (true) {
  424. $head = exif_read_exact($in, 8);
  425. if ($head === null) {
  426. break;
  427. }
  428. $len = unpack('V', substr($head, 4, 4))[1];
  429. $padded = $len + ($len % 2);
  430. if (substr($head, 0, 4) === 'EXIF' && $len <= EXIF_MAX_PARSE_BYTES) {
  431. $data = exif_read_exact($in, $len);
  432. $orientation = $data === null ? 1 : exif_tiff_orientation($data);
  433. break;
  434. }
  435. if (fseek($in, $padded, SEEK_CUR) !== 0) {
  436. break;
  437. }
  438. }
  439. fseek($in, $start);
  440. return $orientation;
  441. }