| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- <?php
- /**
- * Metadata stripping for uploaded images — the per-gallery "strip EXIF" option.
- *
- * Why the server and not the browser
- * ----------------------------------
- * Thumbnails and the resolution cap are browser work, because both need the
- * decoded pixels anyway. Stripping needs none: it is a container rewrite, the
- * uploaded file already sits in a temp file on the webhost, and doing it here
- * means the promise "this gallery carries no EXIF" holds for every upload —
- * including one from a stale cached admin.js or a hand-crafted POST. A gallery
- * with a resolution cap gets stripping for free from the browser's re-encode;
- * this is what makes it available to galleries that keep their originals.
- *
- * What is removed
- * ---------------
- * Everything a camera, phone or editor writes about the photo — EXIF (camera,
- * lens, exposure, timestamps, GPS), XMP, IPTC/Photoshop blocks, comments — but
- * nothing the picture needs to render:
- *
- * - the pixels are never touched: no decode, no re-encode, no quality loss
- * - the ICC colour profile stays, or colours would shift
- * - the JFIF (density) and Adobe (colour transform) blocks stay
- * - the orientation flag is re-written on its own, so a photo shot in
- * portrait still shows upright. It says which way up, not who or where.
- *
- * JPEG, PNG and WebP are understood. Anything else (RAW, AVIF, video, a file
- * the parser does not recognise) is left alone and uploaded as it arrived —
- * best-effort by nature, exactly like the resolution cap. Every entry point
- * verifies the result with getimagesize() before it is used, so a parse that
- * goes wrong costs the strip, never the photo.
- */
- declare(strict_types=1);
- /** Metadata segments/chunks bigger than this are skipped rather than examined. */
- const EXIF_MAX_PARSE_BYTES = 1024 * 1024;
- /**
- * Write a metadata-free copy of $src to a temp file and return its path — the
- * caller owns that file and must unlink it. Returns null when the format is not
- * understood, when there was nothing to strip, or when the rewrite produced
- * anything other than the same image at the same size; in every one of those
- * cases the caller should simply use the original.
- */
- function exif_strip_copy(string $src): ?string
- {
- $in = @fopen($src, 'rb');
- if ($in === false) {
- return null;
- }
- $format = exif_detect_format((string)fread($in, 12));
- if ($format === null) {
- fclose($in);
- return null;
- }
- rewind($in);
- $dest = @tempnam(sys_get_temp_dir(), 'fpexif');
- $out = $dest !== false ? @fopen($dest, 'wb') : false;
- if ($dest === false || $out === false) {
- fclose($in);
- if ($dest !== false) {
- @unlink($dest);
- }
- return null;
- }
- try {
- $removed = match ($format) {
- 'jpeg' => exif_strip_jpeg($in, $out),
- 'png' => exif_strip_png($in, $out),
- 'webp' => exif_strip_webp($in, $out),
- };
- } catch (Throwable $e) {
- $removed = false;
- }
- fclose($in);
- fclose($out);
- // A rewrite that removed nothing is a byte-for-byte copy: drop it and let
- // the original go up, saving a second read of the whole file.
- if (!$removed || !exif_same_image($src, $dest)) {
- @unlink($dest);
- return null;
- }
- return $dest;
- }
- /** 'jpeg' | 'png' | 'webp' from the first bytes of a file, or null. */
- function exif_detect_format(string $head): ?string
- {
- if (str_starts_with($head, "\xFF\xD8\xFF")) {
- return 'jpeg';
- }
- if (str_starts_with($head, "\x89PNG\r\n\x1A\n")) {
- return 'png';
- }
- if (str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP') {
- return 'webp';
- }
- return null;
- }
- /**
- * The safety net: the stripped file must still be the same image. Anything the
- * parser got wrong — a truncated copy, a segment length misread, a container we
- * only thought we understood — shows up here as a failed or differing
- * getimagesize(), and the stripped copy is thrown away.
- */
- function exif_same_image(string $src, string $dest): bool
- {
- $a = @getimagesize($src);
- $b = @getimagesize($dest);
- return is_array($a) && is_array($b)
- && $a[0] === $b[0] && $a[1] === $b[1] && $a[2] === $b[2];
- }
- // ---------------------------------------------------------------------------
- // TIFF (the block inside an EXIF segment)
- // ---------------------------------------------------------------------------
- /**
- * The Orientation tag (0x0112) of a TIFF/EXIF block, or 1 ("upright") when it
- * is absent or unreadable. Only IFD0 is walked: orientation lives there, and a
- * block this code cannot follow simply reads as upright — the same thing a
- * viewer does with a missing tag.
- */
- function exif_tiff_orientation(string $tiff): int
- {
- if (strlen($tiff) < 8) {
- return 1;
- }
- // Byte order is declared by the block itself: 'II' little-endian, 'MM' big.
- $order = substr($tiff, 0, 2);
- if ($order === 'II') {
- [$short, $long] = ['v', 'V'];
- } elseif ($order === 'MM') {
- [$short, $long] = ['n', 'N'];
- } else {
- return 1;
- }
- if (unpack($short, substr($tiff, 2, 2))[1] !== 42) {
- return 1;
- }
- $ifd = unpack($long, substr($tiff, 4, 4))[1];
- if ($ifd < 8 || $ifd + 2 > strlen($tiff)) {
- return 1;
- }
- $count = unpack($short, substr($tiff, $ifd, 2))[1];
- for ($i = 0; $i < $count; $i++) {
- $entry = $ifd + 2 + $i * 12;
- if ($entry + 12 > strlen($tiff)) {
- break;
- }
- if (unpack($short, substr($tiff, $entry, 2))[1] !== 0x0112) {
- continue;
- }
- // Type 3 = SHORT, and a single one fits in the entry's value field.
- if (unpack($short, substr($tiff, $entry + 2, 2))[1] !== 3) {
- break;
- }
- $value = unpack($short, substr($tiff, $entry + 8, 2))[1];
- return $value >= 1 && $value <= 8 ? $value : 1;
- }
- return 1;
- }
- /**
- * A complete TIFF block holding one tag: Orientation. 26 bytes, big-endian,
- * one IFD, no thumbnail, no maker note — the whole point being that this is
- * everything we are willing to keep.
- */
- function exif_minimal_tiff(int $orientation): string
- {
- return "MM\x00\x2A" . pack('N', 8) // header, IFD0 starts at byte 8
- . pack('n', 1) // one entry
- . pack('n', 0x0112) . pack('n', 3) . pack('N', 1)
- . pack('n', $orientation) . "\x00\x00" // SHORT, left-aligned in 4 bytes
- . pack('N', 0); // no IFD1
- }
- // ---------------------------------------------------------------------------
- // Stream helpers
- // ---------------------------------------------------------------------------
- /** Exactly $len bytes, or null if the stream ended early. */
- function exif_read_exact($fh, int $len): ?string
- {
- $buf = '';
- while (strlen($buf) < $len) {
- $chunk = fread($fh, $len - strlen($buf));
- if ($chunk === false || $chunk === '') {
- return null;
- }
- $buf .= $chunk;
- }
- return $buf;
- }
- /** Copy $len bytes across without holding them in memory. */
- function exif_copy_bytes($in, $out, int $len): bool
- {
- return $len === 0 || stream_copy_to_stream($in, $out, $len) === $len;
- }
- // ---------------------------------------------------------------------------
- // JPEG
- // ---------------------------------------------------------------------------
- /**
- * JPEG is a chain of marker segments (0xFF, marker, 2-byte length, payload)
- * ending at the start-of-scan, after which the entropy-coded image data runs to
- * the end of the file. Metadata lives entirely in the segments, so stripping is
- * a copy that skips some of them and never looks at the scan.
- */
- function exif_strip_jpeg($in, $out): bool
- {
- if (fread($in, 2) !== "\xFF\xD8") {
- return false;
- }
- fwrite($out, "\xFF\xD8");
- $removed = false;
- while (true) {
- $head = exif_read_exact($in, 2);
- if ($head === null || $head[0] !== "\xFF") {
- return false;
- }
- $marker = ord($head[1]);
- // Start of scan: the rest of the file is image data, copied verbatim.
- if ($marker === 0xDA) {
- fwrite($out, $head);
- return stream_copy_to_stream($in, $out) !== false && $removed;
- }
- // Markers that carry no payload (only 0x01 and the restart markers can
- // legally appear out here, but passing any of them through keeps a file
- // with padding between segments intact).
- if ($marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD9)) {
- fwrite($out, $head);
- continue;
- }
- $lenBytes = exif_read_exact($in, 2);
- if ($lenBytes === null) {
- return false;
- }
- $len = unpack('n', $lenBytes)[1];
- if ($len < 2) {
- return false;
- }
- $payloadLen = $len - 2;
- // Only APPn and COM can hold metadata; everything else (quantisation
- // tables, Huffman tables, frame headers) is structure and streams past.
- $isApp = $marker >= 0xE0 && $marker <= 0xEF;
- if (!$isApp && $marker !== 0xFE) {
- fwrite($out, $head . $lenBytes);
- if (!exif_copy_bytes($in, $out, $payloadLen)) {
- return false;
- }
- continue;
- }
- // An APP segment is at most 64 KB, so reading it whole is cheap — and
- // the decision needs its first bytes anyway.
- $payload = exif_read_exact($in, $payloadLen);
- if ($payload === null) {
- return false;
- }
- if (exif_jpeg_segment_is_structural($marker, $payload)) {
- fwrite($out, $head . $lenBytes . $payload);
- continue;
- }
- $removed = true;
- // The one thing worth rescuing: how the camera was held. Re-emitted as
- // a segment holding that tag and nothing else, in place of the original.
- if ($marker === 0xE1 && str_starts_with($payload, "Exif\x00\x00")) {
- $orientation = exif_tiff_orientation(substr($payload, 6));
- if ($orientation > 1) {
- $slim = "Exif\x00\x00" . exif_minimal_tiff($orientation);
- fwrite($out, "\xFF\xE1" . pack('n', strlen($slim) + 2) . $slim);
- }
- }
- }
- }
- /**
- * True for the few APP segments that describe how to render the image rather
- * than where it came from. Everything else — EXIF and XMP (APP1), IPTC and the
- * Photoshop resource block (APP13), FlashPix, vendor blocks, comments — goes.
- */
- function exif_jpeg_segment_is_structural(int $marker, string $payload): bool
- {
- return match ($marker) {
- 0xE0 => true, // JFIF: pixel density
- 0xE2 => str_starts_with($payload, "ICC_PROFILE\x00"), // colour profile
- 0xEE => str_starts_with($payload, 'Adobe'), // colour transform
- default => false,
- };
- }
- // ---------------------------------------------------------------------------
- // PNG
- // ---------------------------------------------------------------------------
- /** Chunks that hold metadata rather than image data. */
- const EXIF_PNG_DROP_CHUNKS = ['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME'];
- /**
- * PNG is a signature followed by length/type/data/CRC chunks. Dropping one is
- * simply not copying it; because every chunk carries its own CRC, nothing has
- * to be recomputed for the chunks that stay.
- */
- function exif_strip_png($in, $out): bool
- {
- $sig = exif_read_exact($in, 8);
- if ($sig === null) {
- return false;
- }
- fwrite($out, $sig);
- $removed = false;
- while (true) {
- $head = exif_read_exact($in, 8);
- if ($head === null) {
- return false; // ran out before IEND
- }
- $len = unpack('N', substr($head, 0, 4))[1];
- $type = substr($head, 4, 4);
- if (in_array($type, EXIF_PNG_DROP_CHUNKS, true)) {
- // Only eXIf is worth reading (for the orientation); the rest is
- // skipped without ever being held in memory.
- $data = null;
- if ($type === 'eXIf' && $len <= EXIF_MAX_PARSE_BYTES) {
- $data = exif_read_exact($in, $len);
- if ($data === null) {
- return false;
- }
- } elseif (fseek($in, $len, SEEK_CUR) !== 0) {
- return false;
- }
- fseek($in, 4, SEEK_CUR); // the chunk's CRC
- $removed = true;
- if ($data !== null && ($orientation = exif_tiff_orientation($data)) > 1) {
- fwrite($out, exif_png_chunk('eXIf', exif_minimal_tiff($orientation)));
- }
- continue;
- }
- fwrite($out, $head);
- if (!exif_copy_bytes($in, $out, $len)) {
- return false;
- }
- $crc = exif_read_exact($in, 4);
- if ($crc === null) {
- return false;
- }
- fwrite($out, $crc);
- // IEND closes the image; anything appended after it is not part of the
- // PNG and is deliberately not carried over.
- if ($type === 'IEND') {
- return $removed;
- }
- }
- }
- /** One PNG chunk, CRC included (PHP's crc32 is the one PNG specifies). */
- function exif_png_chunk(string $type, string $data): string
- {
- return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data));
- }
- // ---------------------------------------------------------------------------
- // WebP
- // ---------------------------------------------------------------------------
- /**
- * WebP is RIFF: a 12-byte header whose size field covers everything after it,
- * then FourCC/size/payload chunks padded to an even length. Metadata sits in
- * the 'EXIF' and 'XMP ' chunks, and an extended file announces their presence
- * in the VP8X flag byte — so dropping them means clearing those bits too, or
- * decoders go looking for chunks that are no longer there.
- *
- * The orientation is read in a first pass, because VP8X (start of file) has to
- * be written before the EXIF chunk (end of file) is reached.
- */
- function exif_strip_webp($in, $out): bool
- {
- $header = exif_read_exact($in, 12);
- if ($header === null) {
- return false;
- }
- $orientation = exif_webp_orientation($in);
- fwrite($out, $header); // RIFF size is patched in at the end
- $payloadBytes = 4; // the 'WEBP' FourCC already written
- $removed = false;
- while (!feof($in)) {
- $head = exif_read_exact($in, 8);
- if ($head === null) {
- break; // clean end of file
- }
- $type = substr($head, 0, 4);
- $len = unpack('V', substr($head, 4, 4))[1];
- $padded = $len + ($len % 2);
- if ($type === 'EXIF' || $type === 'XMP ') {
- if (fseek($in, $padded, SEEK_CUR) !== 0) {
- return false;
- }
- $removed = true;
- if ($type === 'EXIF' && $orientation > 1) {
- $slim = exif_minimal_tiff($orientation);
- fwrite($out, 'EXIF' . pack('V', strlen($slim)) . $slim);
- $payloadBytes += 8 + strlen($slim);
- }
- continue;
- }
- if ($type === 'VP8X' && $len >= 10) {
- $data = exif_read_exact($in, $padded);
- if ($data === null) {
- return false;
- }
- // Bit 3 = EXIF present, bit 2 = XMP present. The EXIF bit survives
- // only when an orientation-only chunk is being written back.
- $flags = ord($data[0]) & ~0x0C;
- if ($orientation > 1) {
- $flags |= 0x08;
- }
- $data[0] = chr($flags);
- fwrite($out, $head . $data);
- $payloadBytes += 8 + $padded;
- continue;
- }
- fwrite($out, $head);
- if (!exif_copy_bytes($in, $out, $padded)) {
- return false;
- }
- $payloadBytes += 8 + $padded;
- }
- // RIFF states its own length, which just changed.
- if (fseek($out, 4) !== 0) {
- return false;
- }
- fwrite($out, pack('V', $payloadBytes));
- return $removed;
- }
- /** Orientation from a WebP's EXIF chunk, leaving $in rewound for the real pass. */
- function exif_webp_orientation($in): int
- {
- $start = ftell($in);
- $orientation = 1;
- while (true) {
- $head = exif_read_exact($in, 8);
- if ($head === null) {
- break;
- }
- $len = unpack('V', substr($head, 4, 4))[1];
- $padded = $len + ($len % 2);
- if (substr($head, 0, 4) === 'EXIF' && $len <= EXIF_MAX_PARSE_BYTES) {
- $data = exif_read_exact($in, $len);
- $orientation = $data === null ? 1 : exif_tiff_orientation($data);
- break;
- }
- if (fseek($in, $padded, SEEK_CUR) !== 0) {
- break;
- }
- }
- fseek($in, $start);
- return $orientation;
- }
|