Jelajahi Sumber

adding option to strip exif on upload

Medowar 1 bulan lalu
induk
melakukan
12b055a2f7
11 mengubah file dengan 603 tambahan dan 29 penghapusan
  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
   showreel, create galleries and bulk-upload images. Uploads go straight from
   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
   live in a PHP config file. Password can be changed online.
 

+ 2 - 1
admin/api.php

@@ -10,7 +10,8 @@
  *
  * Fields:
  *   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)
  *   batch     id of the selection this file came from (optional)
  *   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,
             // Longest edge the browser downscales uploads to; null = original.
             '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'        => [],
         ];
         // 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>
     <input type="date" id="ex" name="expires_at">
     <?php resolution_field() ?>
+    <?php strip_exif_field() ?>
     <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
     </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['upload_key']) ? ' <span class="tag">uploads</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>
             <?= 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
         // always posts, and an empty value genuinely means "back to Original".
         $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'])) {
             $gallery['password_hash'] = null;
         } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
@@ -159,13 +161,7 @@ flash_render();
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         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>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>
@@ -277,6 +273,7 @@ flash_render();
     <label for="ex">Expiry date (blank = never)</label>
     <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 strip_exif_field(!empty($gallery['strip_exif'])) ?>
     <label for="p">Set new password (blank = keep current)</label>
     <input type="text" id="p" name="password" autocomplete="off">
     <?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/csrf.php';
 require APP_ROOT . '/app/auth.php';
+require APP_ROOT . '/app/exif.php';
 require APP_ROOT . '/app/s3.php';
 require APP_ROOT . '/app/zip.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">
         Images larger than this are downscaled in the browser before uploading,
         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>
     <?php
     static $scriptDone = false;
@@ -141,6 +139,47 @@ function resolution_field(?int $current = null): void
     <?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). */
 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
  * 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 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
@@ -685,9 +689,25 @@ function gallery_store_s3_upload(
     $base  = s3_gallery_prefix($slug);
     $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';
-    [$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) {
         return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
     }
@@ -714,7 +734,7 @@ function gallery_store_s3_upload(
         'key'   => $key,
         'thumb' => $thumbKey,
         'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
-        'size'  => (int)($original['size'] ?? 0),
+        'size'  => $size,
     ] + upload_order_fields($fields), $topic);
 
     // 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
   *Original* when that matters. Files the browser cannot read, such as RAW, are
   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
-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
 `/gallery/?g=wedding-mueller-x7Kf3q` — copy the *Share link* from the
 gallery editor and send it to your client.
 
 **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
 generated by your browser for the gallery grid; files the browser cannot
 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
   auth.php         login, throttling, online password change
   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
   archive.php      archive build slices, dirty queue, worker dispatch
   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
     "expires_at": "2026-12-31",         // or null
     "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
     "topics": [                         // optional sections, in display order
       { "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 |
 | 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)
 
@@ -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
 main-thread canvas work, and concurrent full-size bitmaps are what actually
 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
 root).
 

+ 1 - 7
upload.php

@@ -76,13 +76,7 @@ public_header(e($gallery['title']));
          data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
          data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         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>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>