Bladeren bron

adding option to strip exif on upload

Medowar 1 maand geleden
bovenliggende
commit
12b055a2f7
11 gewijzigde bestanden met toevoegingen van 603 en 29 verwijderingen
  1. 2 1
      README.md
  2. 2 1
      admin/api.php
  3. 4 0
      admin/galleries.php
  4. 4 7
      admin/gallery-edit.php
  5. 1 0
      app/bootstrap.php
  6. 481 0
      app/exif.php
  7. 42 3
      app/partials.php
  8. 23 3
      app/s3.php
  9. 12 4
      docs/ADMIN-GUIDE.md
  10. 31 3
      docs/ARCHITECTURE.md
  11. 1 7
      upload.php

+ 2 - 1
README.md

@@ -21,7 +21,8 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
 - **Admin backoffice** — a small CMS to edit the front page, manage the
 - **Admin backoffice** — a small CMS to edit the front page, manage the
   showreel, create galleries and bulk-upload images. Uploads go straight from
   showreel, create galleries and bulk-upload images. Uploads go straight from
   the browser to S3, in full resolution, originals untouched; grid thumbnails
   the browser to S3, in full resolution, originals untouched; grid thumbnails
-  are generated in the browser.
+  are generated in the browser. A gallery can optionally cap its resolution or
+  strip EXIF metadata (camera, lens, timestamps, GPS) from what it stores.
 - **Flat-file storage** — all content lives in JSON files; admin credentials
 - **Flat-file storage** — all content lives in JSON files; admin credentials
   live in a PHP config file. Password can be changed online.
   live in a PHP config file. Password can be changed online.
 
 

+ 2 - 1
admin/api.php

@@ -10,7 +10,8 @@
  *
  *
  * Fields:
  * Fields:
  *   slug      gallery slug
  *   slug      gallery slug
- *   original  the full-resolution file (required, stored unmodified)
+ *   original  the full-resolution file (required; stored unmodified unless the
+ *             gallery strips metadata — see app/exif.php)
  *   thumb     browser-generated JPEG thumbnail (optional; absent for RAW/video)
  *   thumb     browser-generated JPEG thumbnail (optional; absent for RAW/video)
  *   batch     id of the selection this file came from (optional)
  *   batch     id of the selection this file came from (optional)
  *   seq       its position within that selection, so parallel uploads are
  *   seq       its position within that selection, so parallel uploads are

+ 4 - 0
admin/galleries.php

@@ -25,6 +25,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
             'expires_at'    => trim((string)($_POST['expires_at'] ?? '')) ?: null,
             'expires_at'    => trim((string)($_POST['expires_at'] ?? '')) ?: null,
             // Longest edge the browser downscales uploads to; null = original.
             // Longest edge the browser downscales uploads to; null = original.
             'max_resolution' => parse_max_resolution($_POST),
             'max_resolution' => parse_max_resolution($_POST),
+            // Drop EXIF/XMP/IPTC from uploads on the way to S3 (app/exif.php).
+            'strip_exif'    => !empty($_POST['strip_exif']),
             'images'        => [],
             'images'        => [],
         ];
         ];
         // A guest upload link is just a per-gallery secret in the URL; presence
         // A guest upload link is just a per-gallery secret in the URL; presence
@@ -66,6 +68,7 @@ flash_render();
     <label for="ex">Expiry date <span style="text-transform:none;letter-spacing:0">(optional — gallery is hidden after this day)</span></label>
     <label for="ex">Expiry date <span style="text-transform:none;letter-spacing:0">(optional — gallery is hidden after this day)</span></label>
     <input type="date" id="ex" name="expires_at">
     <input type="date" id="ex" name="expires_at">
     <?php resolution_field() ?>
     <?php resolution_field() ?>
+    <?php strip_exif_field() ?>
     <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
     <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
         <input type="checkbox" name="allow_uploads" value="1"> Allow guest uploads via a shared link
         <input type="checkbox" name="allow_uploads" value="1"> Allow guest uploads via a shared link
     </label></p>
     </label></p>
@@ -96,6 +99,7 @@ flash_render();
             <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
             <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
             <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>
             <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>
             <?= isset($g['max_resolution']) ? ' <span class="tag">' . e(resolution_label((int)$g['max_resolution'])) . '</span>' : '' ?>
             <?= isset($g['max_resolution']) ? ' <span class="tag">' . e(resolution_label((int)$g['max_resolution'])) . '</span>' : '' ?>
+            <?= !empty($g['strip_exif']) ? ' <span class="tag">no exif</span>' : '' ?>
         </td>
         </td>
         <td>
         <td>
             <?= e($g['expires_at'] ?? '—') ?>
             <?= e($g['expires_at'] ?? '—') ?>

+ 4 - 7
admin/gallery-edit.php

@@ -23,6 +23,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         // Unlike the fields above there is no keep-current fallback: the select
         // Unlike the fields above there is no keep-current fallback: the select
         // always posts, and an empty value genuinely means "back to Original".
         // always posts, and an empty value genuinely means "back to Original".
         $gallery['max_resolution'] = parse_max_resolution($_POST);
         $gallery['max_resolution'] = parse_max_resolution($_POST);
+        // Same for the checkbox: unticked means it is simply absent from $_POST.
+        $gallery['strip_exif'] = !empty($_POST['strip_exif']);
         if (!empty($_POST['remove_password'])) {
         if (!empty($_POST['remove_password'])) {
             $gallery['password_hash'] = null;
             $gallery['password_hash'] = null;
         } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
         } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
@@ -159,13 +161,7 @@ flash_render();
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         Drop images here or click to select.<br>
         Drop images here or click to select.<br>
-        <small>
-            <?php if (isset($gallery['max_resolution'])): ?>
-                Uploaded through the site to S3, downscaled to <?= (int)$gallery['max_resolution'] ?> px on the longest edge.
-            <?php else: ?>
-                Uploaded through the site to S3, in full resolution, unmodified.
-            <?php endif; ?>
-        </small>
+        <small>Uploaded through the site to S3, <?= e(upload_treatment_text($gallery)) ?>.</small>
     </div>
     </div>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>
     <div class="upload-list" id="upload-list"></div>
@@ -277,6 +273,7 @@ flash_render();
     <label for="ex">Expiry date (blank = never)</label>
     <label for="ex">Expiry date (blank = never)</label>
     <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
     <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
     <?php resolution_field(isset($gallery['max_resolution']) ? (int)$gallery['max_resolution'] : null) ?>
     <?php resolution_field(isset($gallery['max_resolution']) ? (int)$gallery['max_resolution'] : null) ?>
+    <?php strip_exif_field(!empty($gallery['strip_exif'])) ?>
     <label for="p">Set new password (blank = keep current)</label>
     <label for="p">Set new password (blank = keep current)</label>
     <input type="text" id="p" name="password" autocomplete="off">
     <input type="text" id="p" name="password" autocomplete="off">
     <?php if (!empty($gallery['password_hash'])): ?>
     <?php if (!empty($gallery['password_hash'])): ?>

+ 1 - 0
app/bootstrap.php

@@ -22,6 +22,7 @@ date_default_timezone_set(config('site.timezone', 'UTC'));
 require APP_ROOT . '/app/storage.php';
 require APP_ROOT . '/app/storage.php';
 require APP_ROOT . '/app/csrf.php';
 require APP_ROOT . '/app/csrf.php';
 require APP_ROOT . '/app/auth.php';
 require APP_ROOT . '/app/auth.php';
+require APP_ROOT . '/app/exif.php';
 require APP_ROOT . '/app/s3.php';
 require APP_ROOT . '/app/s3.php';
 require APP_ROOT . '/app/zip.php';
 require APP_ROOT . '/app/zip.php';
 require APP_ROOT . '/app/archive.php';
 require APP_ROOT . '/app/archive.php';

+ 481 - 0
app/exif.php

@@ -0,0 +1,481 @@
+<?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;
+}

+ 42 - 3
app/partials.php

@@ -118,9 +118,7 @@ function resolution_field(?int $current = null): void
     <p class="help">
     <p class="help">
         Images larger than this are downscaled in the browser before uploading,
         Images larger than this are downscaled in the browser before uploading,
         which also keeps them under the server's upload limit. The re-encode
         which also keeps them under the server's upload limit. The re-encode
-        drops EXIF data (camera, lens, date, location) — choose Original to keep
-        it. Files the browser cannot read, such as RAW, are always uploaded
-        untouched.
+        drops EXIF data (camera, lens, date, location).
     </p>
     </p>
     <?php
     <?php
     static $scriptDone = false;
     static $scriptDone = false;
@@ -141,6 +139,47 @@ function resolution_field(?int $current = null): void
     <?php
     <?php
 }
 }
 
 
+/**
+ * The "strip EXIF" checkbox, shared by the gallery create and settings forms.
+ * $current is the gallery's stored flag.
+ *
+ * It is a separate control from the resolution cap rather than part of it
+ * because the two only overlap: a capped gallery is re-encoded in the browser
+ * and loses its metadata either way, while an "Original" gallery — the case
+ * this exists for — keeps every byte unless this is ticked.
+ */
+function strip_exif_field(bool $current = false): void
+{
+    ?>
+    <p class="help" style="margin-bottom:.4rem"><label style="display:inline;text-transform:none;letter-spacing:0">
+        <input type="checkbox" name="strip_exif" value="1" <?= $current ? 'checked' : '' ?>>
+        Strip EXIF metadata from uploaded images
+    </label></p>
+    <p class="help">
+        Removes camera, lens, exposure, timestamp and GPS data from JPEG, PNG and
+        WebP uploads before they are stored. The photo itself is not re-encoded.
+    </p>
+    <?php
+}
+
+/**
+ * How this gallery treats what is uploaded to it, as a phrase for the dropzone
+ * on the admin editor and the guest upload page ("full resolution, unmodified",
+ * "downscaled to 2560 px on the longest edge, EXIF metadata removed", …).
+ */
+function upload_treatment_text(array $gallery): string
+{
+    $parts = isset($gallery['max_resolution'])
+        ? ['downscaled to ' . (int)$gallery['max_resolution'] . ' px on the longest edge']
+        : ['full resolution'];
+    if (!empty($gallery['strip_exif'])) {
+        $parts[] = 'EXIF metadata removed';
+    } elseif (!isset($gallery['max_resolution'])) {
+        $parts[] = 'unmodified';
+    }
+    return implode(', ', $parts);
+}
+
 /** One-shot status message helpers (flash messages via session). */
 /** One-shot status message helpers (flash messages via session). */
 function flash_set(string $msg, string $kind = 'ok'): void
 function flash_set(string $msg, string $kind = 'ok'): void
 {
 {

+ 23 - 3
app/s3.php

@@ -642,6 +642,10 @@ function upload_order_fields(array $fields): array
  * Object keys are generated server-side under the gallery's own prefix — never
  * Object keys are generated server-side under the gallery's own prefix — never
  * taken from the client.
  * taken from the client.
  *
  *
+ * A gallery with 'strip_exif' set has the metadata blocks removed from the
+ * original on the way through (app/exif.php) — the pixels are never re-encoded,
+ * and a file that cannot be rewritten safely is stored as it arrived.
+ *
  * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
  * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
  * original must have a recognised image extension and decode via getimagesize(),
  * original must have a recognised image extension and decode via getimagesize(),
  * so a public link cannot be used to store arbitrary file types. $fields is the
  * so a public link cannot be used to store arbitrary file types. $fields is the
@@ -685,9 +689,25 @@ function gallery_store_s3_upload(
     $base  = s3_gallery_prefix($slug);
     $base  = s3_gallery_prefix($slug);
     $key   = "$base/originals/$token-$name";
     $key   = "$base/originals/$token-$name";
 
 
-    // Stream the original to S3 byte-for-byte from the PHP upload temp file.
+    // Galleries that strip metadata do it here, on the way past: the upload is
+    // rewritten without its EXIF/XMP/IPTC blocks into a second temp file, and
+    // that is what goes to S3. exif_strip_copy() returns null for anything it
+    // cannot rewrite safely (RAW, an unfamiliar container, a parse that did not
+    // come out as the same image), and then the file goes up as it arrived.
+    $source = (string)$original['tmp_name'];
+    $stripped = !empty($gallery['strip_exif']) ? exif_strip_copy($source) : null;
+    if ($stripped !== null) {
+        $source = $stripped;
+    }
+
+    // Stream the original to S3 byte-for-byte from the temp file on disk.
     $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
     $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
-    [$status] = s3_put_file($key, (string)$original['tmp_name'], $type);
+    [$status] = s3_put_file($key, $source, $type);
+    // Stored size is measured, not taken from the upload: stripping shrinks it.
+    $size = (int)@filesize($source) ?: (int)($original['size'] ?? 0);
+    if ($stripped !== null) {
+        @unlink($stripped);
+    }
     if ($status < 200 || $status >= 300) {
     if ($status < 200 || $status >= 300) {
         return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
         return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
     }
     }
@@ -714,7 +734,7 @@ function gallery_store_s3_upload(
         'key'   => $key,
         'key'   => $key,
         'thumb' => $thumbKey,
         'thumb' => $thumbKey,
         'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
         'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
-        'size'  => (int)($original['size'] ?? 0),
+        'size'  => $size,
     ] + upload_order_fields($fields), $topic);
     ] + upload_order_fields($fields), $topic);
 
 
     // The gallery was deleted while this image was in flight: drop the objects
     // The gallery was deleted while this image was in flight: drop the objects

+ 12 - 4
docs/ADMIN-GUIDE.md

@@ -35,18 +35,26 @@ the webhost.
   image drops its EXIF data (camera, lens, date, location), so choose
   image drops its EXIF data (camera, lens, date, location), so choose
   *Original* when that matters. Files the browser cannot read, such as RAW, are
   *Original* when that matters. Files the browser cannot read, such as RAW, are
   uploaded at full size regardless.
   uploaded at full size regardless.
+- **Strip EXIF metadata** — remove the camera, lens, exposure, timestamp and
+  **GPS** data from every image uploaded into this gallery. The photo itself is
+  not re-saved, so nothing is lost in quality; only the metadata blocks are
+  taken out on the way to storage, and the orientation flag is kept so portrait
+  shots still show upright. Use it together with *Original* when clients should
+  get untouched pixels but no data about where and how the photos were taken.
+  JPEG, PNG and WebP are handled; formats the server cannot rewrite, such as
+  RAW, are stored as they arrive.
 
 
 All of these can be changed later in the gallery editor. Changing the
 All of these can be changed later in the gallery editor. Changing the
-resolution affects new uploads only; images already in the gallery stay as they
-were stored.
+resolution or the EXIF setting affects new uploads only; images already in the
+gallery stay as they were stored.
 
 
 Each gallery gets an unguessable link like
 Each gallery gets an unguessable link like
 `/gallery/?g=wedding-mueller-x7Kf3q` — copy the *Share link* from the
 `/gallery/?g=wedding-mueller-x7Kf3q` — copy the *Share link* from the
 gallery editor and send it to your client.
 gallery editor and send it to your client.
 
 
 **Upload images** by dropping them onto the upload area in the gallery editor.
 **Upload images** by dropping them onto the upload area in the gallery editor.
-Unless the gallery caps its resolution (above), files are stored in **full
-resolution, byte-for-byte unmodified**. Keep the browser tab open until every file
+Unless the gallery caps its resolution or strips metadata (above), files are
+stored in **full resolution, byte-for-byte unmodified**. Keep the browser tab open until every file
 shows *done*; failed files offer a *retry* link. A small preview thumbnail is
 shows *done*; failed files offer a *retry* link. A small preview thumbnail is
 generated by your browser for the gallery grid; files the browser cannot
 generated by your browser for the gallery grid; files the browser cannot
 decode (e.g. RAW) are uploaded anyway, just without a preview.
 decode (e.g. RAW) are uploaded anyway, just without a preview.

+ 31 - 3
docs/ARCHITECTURE.md

@@ -30,6 +30,7 @@ app/               library code — blocked by .htaccess
   storage.php      JSON flat-file store, slugs, local media handling
   storage.php      JSON flat-file store, slugs, local media handling
   auth.php         login, throttling, online password change
   auth.php         login, throttling, online password change
   s3.php           AWS Signature v4 (presign, PUT, DELETE, GET, multipart)
   s3.php           AWS Signature v4 (presign, PUT, DELETE, GET, multipart)
+  exif.php         metadata stripping for uploads (JPEG/PNG/WebP containers)
   zip.php          store-only ZIP64 writer
   zip.php          store-only ZIP64 writer
   archive.php      archive build slices, dirty queue, worker dispatch
   archive.php      archive build slices, dirty queue, worker dispatch
   migrate.php      numbered schema migrations + the schema version constant
   migrate.php      numbered schema migrations + the schema version constant
@@ -53,6 +54,7 @@ router.php         local dev only: applies the .htaccess rules under php -S
     "password_hash": "$2y$...",        // or null
     "password_hash": "$2y$...",        // or null
     "expires_at": "2026-12-31",         // or null
     "expires_at": "2026-12-31",         // or null
     "max_resolution": 2560,             // longest edge in px, or null = original
     "max_resolution": 2560,             // longest edge in px, or null = original
+    "strip_exif": true,                 // remove metadata from uploads
     "schema_version": 1,                // absent on files older than admin/migrate.php
     "schema_version": 1,                // absent on files older than admin/migrate.php
     "topics": [                         // optional sections, in display order
     "topics": [                         // optional sections, in display order
       { "id": "t7k3f9a", "name": "Day 1" }
       { "id": "t7k3f9a", "name": "Day 1" }
@@ -122,8 +124,12 @@ delete anything.
 | Hero + showreel | `media/` on the webhost | Few images, served directly, no S3 round-trip for the portfolio |
 | Hero + showreel | `media/` on the webhost | Few images, served directly, no S3 round-trip for the portfolio |
 | Gallery images | Hetzner S3, **private** bucket | Hundreds of full-res files per event; webspace stays small; traffic goes to S3 |
 | Gallery images | Hetzner S3, **private** bucket | Hundreds of full-res files per event; webspace stays small; traffic goes to S3 |
 
 
-Originals are **never modified** anywhere in the pipeline — no resize, no
-re-encode, no EXIF stripping.
+Originals are **never re-encoded** anywhere in the pipeline. Two per-gallery
+options change what is stored, both off by default: `max_resolution` downscales
+in the browser before the upload, and `strip_exif` removes the metadata blocks
+on the webhost. Neither ever decodes and re-compresses an original the server
+has received — stripping is a container rewrite, and the pixels come out
+bit-identical.
 
 
 ## Presigned URLs (app/s3.php)
 ## Presigned URLs (app/s3.php)
 
 
@@ -179,7 +185,29 @@ fine for a thumbnail but not for pixels about to be stored. To pay for that,
 decode and resize run one file at a time even while uploads overlap — it is
 decode and resize run one file at a time even while uploads overlap — it is
 main-thread canvas work, and concurrent full-size bitmaps are what actually
 main-thread canvas work, and concurrent full-size bitmaps are what actually
 exhausts a phone. Second, undecodable files (RAW) ignore the cap and upload
 exhausts a phone. Second, undecodable files (RAW) ignore the cap and upload
-whole, so it is best-effort, not enforced: the server stores what arrives. Object keys are laid out as `<prefix>/<slug>/{originals,thumbs}/…`,
+whole, so it is best-effort, not enforced: the server stores what arrives.
+
+A gallery may also strip metadata (`strip_exif`, `app/exif.php`). Unlike the
+cap this runs on the webhost, between the upload temp file and the S3 PUT: the
+file is rewritten without its EXIF, XMP, IPTC/Photoshop and comment blocks into
+a second temp file, which is what goes up. Doing it server-side rather than in
+`admin.js` means the guarantee holds for every upload, including one from a
+stale cached uploader or a hand-made POST, and it costs nothing extra — the
+bytes are already sitting in a temp file.
+
+The pixels are never touched: no decode, no re-encode, no quality change. What
+the picture needs to render stays — the ICC colour profile, the JFIF density
+block, the Adobe colour-transform block — and the orientation flag is re-emitted
+in a segment holding that tag and nothing else, so a portrait photo is not
+turned on its side by having its metadata removed. JPEG (marker segments), PNG
+(chunks) and WebP (RIFF chunks, including the VP8X presence flags) are
+understood; anything else, RAW included, is uploaded exactly as it arrived. The
+result is checked with `getimagesize()` against the input before it is used, so
+a parse that goes wrong costs the strip and never the photo. A capped gallery
+gets stripping for free from the browser's re-encode, which is why the two are
+separate switches.
+
+Object keys are laid out as `<prefix>/<slug>/{originals,thumbs}/…`,
 where `<prefix>` comes from `s3.prefix` (default `galleries`, `''` = bucket
 where `<prefix>` comes from `s3.prefix` (default `galleries`, `''` = bucket
 root).
 root).
 
 

+ 1 - 7
upload.php

@@ -76,13 +76,7 @@ public_header(e($gallery['title']));
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         Drop images here or click to select.<br>
         Drop images here or click to select.<br>
-        <small>
-            <?php if (isset($gallery['max_resolution'])): ?>
-                Downscaled to <?= (int)$gallery['max_resolution'] ?> px on the longest edge.
-            <?php else: ?>
-                Full resolution, unmodified.
-            <?php endif; ?>
-        </small>
+        <small><?= e(ucfirst(upload_treatment_text($gallery))) ?>.</small>
     </div>
     </div>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>
     <div class="upload-list" id="upload-list"></div>