Просмотр исходного кода

added download all button to gallery, added zip archive generation

Medowar 6 часов назад
Родитель
Сommit
e6ba67d306
18 измененных файлов с 1903 добавлено и 92 удалено
  1. 2 2
      .htaccess
  2. 71 0
      admin/archive-api.php
  3. 75 0
      admin/gallery-edit.php
  4. 705 0
      app/archive.php
  5. 18 0
      app/bootstrap.php
  6. 4 0
      app/partials.php
  7. 289 78
      app/s3.php
  8. 40 3
      app/storage.php
  9. 226 0
      app/zip.php
  10. 144 0
      assets/archive.js
  11. 39 0
      assets/site.css
  12. 24 0
      config/config.sample.php
  13. 70 2
      docs/ARCHITECTURE.md
  14. 30 0
      docs/SETUP.md
  15. 45 0
      gallery/download.php
  16. 27 6
      gallery/index.php
  17. 1 1
      router.php
  18. 93 0
      worker.php

+ 2 - 2
.htaccess

@@ -12,13 +12,13 @@ DirectoryIndex index.php
 
 
 # Never serve dotfiles (.htaccess, .git*, …), flat-file data, docs, config
 # Never serve dotfiles (.htaccess, .git*, …), flat-file data, docs, config
 # templates or lock files as plain text — regardless of directory.
 # templates or lock files as plain text — regardless of directory.
-<FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock)$|\.sample\.php$)">
+<FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock|buf)$|\.sample\.php$)">
     Require all denied
     Require all denied
 </FilesMatch>
 </FilesMatch>
 
 
 # Belt-and-suspenders for older Apache that lacks the FilesMatch above.
 # Belt-and-suspenders for older Apache that lacks the FilesMatch above.
 <IfModule !mod_authz_core.c>
 <IfModule !mod_authz_core.c>
-    <FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock)$|\.sample\.php$)">
+    <FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock|buf)$|\.sample\.php$)">
         Order allow,deny
         Order allow,deny
         Deny from all
         Deny from all
     </FilesMatch>
     </FilesMatch>

+ 71 - 0
admin/archive-api.php

@@ -0,0 +1,71 @@
+<?php
+/**
+ * Admin JSON API for building a gallery's ZIP archive on demand.
+ *
+ * The archive normally rebuilds itself in the background (worker.php), but the
+ * photographer sometimes wants it *now* — before sending the link out — and
+ * wants to watch it happen. This drives the same archive_run_slice() the worker
+ * uses, one slice per request, with assets/archive.js looping until it reports
+ * finished. Every request therefore stays a few seconds under the host's 60 s
+ * cap no matter how large the gallery is.
+ *
+ * Fields: slug, action (start | step | cancel | delete).
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+if (!auth_check()) {
+    json_response(['error' => 'Not authenticated'], 401);
+}
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    json_response(['error' => 'POST only'], 405);
+}
+csrf_verify();
+
+// Release the session lock before the slow S3 leg, exactly as the uploader
+// does: PHP holds it for the whole request, and the admin would otherwise not
+// be able to load another page while a build is running.
+session_write_close();
+
+@set_time_limit(0);   // honoured on some hosts; the slice budget is the real cap
+
+$slug = (string)($_POST['slug'] ?? '');
+$gallery = gallery_load($slug);
+if ($gallery === null) {
+    json_response(['error' => 'Unknown gallery'], 404);
+}
+
+$startedAt = microtime(true);
+
+// One build at a time site-wide, shared with the background worker — otherwise
+// a manual rebuild and a background one would fight over the same state file.
+// Wait a little rather than failing instantly: the worker holds the lock through
+// short sleeps while a gallery settles, and an admin who asked for this should
+// not lose a race to one of them.
+$lock = archive_lock(15);
+if ($lock === null) {
+    json_response(['error' => 'A background rebuild is running. It will finish on its own — or try again in a moment.'], 409);
+}
+
+// Whatever the wait above cost comes out of the slice, so the whole request
+// still fits inside one step_seconds and cannot drift towards the 60 s cap.
+$budget = max(5.0, (float)config('archive.step_seconds', 25) - (microtime(true) - $startedAt));
+
+switch ((string)($_POST['action'] ?? '')) {
+    case 'start':
+        archive_abort($slug);   // discard any half-finished attempt
+        json_response(archive_run_slice($slug, $budget));
+
+    case 'step':
+        json_response(archive_run_slice($slug, $budget));
+
+    case 'cancel':
+        archive_abort($slug);
+        json_response(['ok' => true]);
+
+    case 'delete':
+        archive_delete($slug);
+        json_response(['ok' => true]);
+
+    default:
+        json_response(['error' => 'Unknown action'], 400);
+}

+ 75 - 0
admin/gallery-edit.php

@@ -51,6 +51,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         redirect('gallery-edit.php?g=' . rawurlencode($slug));
         redirect('gallery-edit.php?g=' . rawurlencode($slug));
     }
     }
 
 
+    if ($action === 'downloads') {
+        $gallery['downloads_enabled'] = !empty($_POST['enabled']);
+        gallery_save($gallery);
+        if ($gallery['downloads_enabled']) {
+            // Queue the first build; the background worker picks it up once the
+            // gallery has been quiet for archive.settle_seconds.
+            archive_mark_dirty($slug, $gallery);
+            flash_set('Downloads enabled. The archive will be built in the background.');
+        } else {
+            archive_delete($slug);
+            flash_set('Downloads disabled and the archive removed.');
+        }
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+
     if ($action === 'delete-image') {
     if ($action === 'delete-image') {
         $key = (string)($_POST['key'] ?? '');
         $key = (string)($_POST['key'] ?? '');
         foreach ($gallery['images'] ?? [] as $i => $img) {
         foreach ($gallery['images'] ?? [] as $i => $img) {
@@ -61,6 +76,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
                 }
                 }
                 array_splice($gallery['images'], $i, 1);
                 array_splice($gallery['images'], $i, 1);
                 gallery_save($gallery);
                 gallery_save($gallery);
+                // The archive no longer matches the gallery's contents.
+                archive_mark_dirty($slug, $gallery);
                 flash_set('Image deleted.');
                 flash_set('Image deleted.');
                 break;
                 break;
             }
             }
@@ -154,6 +171,64 @@ flash_render();
     <?php endif; ?>
     <?php endif; ?>
 </div>
 </div>
 
 
+<?php $status = archive_status($gallery); ?>
+<div class="card" id="archive-card"
+     data-api="archive-api.php"
+     data-slug="<?= e($slug) ?>"
+     data-csrf="<?= e(csrf_token()) ?>">
+    <h2 style="margin-top:0">Download all</h2>
+    <p class="help" style="margin-bottom:1rem">
+        Offers visitors a single ZIP of every photo. It is assembled once and
+        stored on S3, so the download itself never runs through this webhost —
+        and it is rebuilt automatically whenever images are added or removed.
+        While a rebuild is pending the button on the gallery is disabled, so
+        nobody receives an archive that is missing the newest photos.
+    </p>
+
+    <form method="post" style="margin-bottom:1rem">
+        <?= csrf_field() ?>
+        <input type="hidden" name="action" value="downloads">
+        <input type="hidden" name="enabled" value="<?= empty($gallery['downloads_enabled']) ? '1' : '0' ?>">
+        <?php if (empty($gallery['downloads_enabled'])): ?>
+            <button style="margin:0">Enable downloads</button>
+        <?php else: ?>
+            <button class="btn-danger" style="margin:0"
+                    onclick="return confirm('Disable downloads and delete the archive from S3?')">
+                Disable downloads
+            </button>
+        <?php endif; ?>
+    </form>
+
+    <?php if (!empty($gallery['downloads_enabled'])): ?>
+        <p id="archive-status" class="help" style="margin-bottom:.6rem">
+            <?php if ($status['archive'] && !$status['stale']): ?>
+                Ready · <?= e(human_bytes((int)$status['archive']['size'])) ?>
+                · <?= (int)$status['archive']['count'] ?> photos
+                · built <?= e($status['archive']['built_at']) ?>
+            <?php elseif ($status['building']): ?>
+                Building — <?= (int)$status['done'] ?> of <?= (int)$status['total'] ?> photos done.
+            <?php elseif ($status['queued'] && $status['due_in'] > 0): ?>
+                Queued — the rebuild starts in about <?= (int)ceil($status['due_in'] / 60) ?> min.
+            <?php elseif ($status['queued']): ?>
+                Queued — the rebuild starts shortly.
+            <?php elseif ($status['archive']): ?>
+                Out of date — the archive does not match the current images.
+            <?php else: ?>
+                No archive yet.
+            <?php endif; ?>
+        </p>
+
+        <div id="archive-bar" class="archive-bar" hidden><span></span></div>
+
+        <button id="archive-build" style="margin:0"
+                <?= $status['building'] ? 'data-resume="1"' : '' ?>>
+            <?= $status['building'] ? 'Resume build' : ($status['archive'] ? 'Rebuild now' : 'Build now') ?>
+        </button>
+        <button id="archive-cancel" class="btn-ghost" style="margin:0" hidden>Cancel</button>
+        <script src="../assets/archive.js" defer></script>
+    <?php endif; ?>
+</div>
+
 <form method="post" class="card">
 <form method="post" class="card">
     <?= csrf_field() ?>
     <?= csrf_field() ?>
     <input type="hidden" name="action" value="settings">
     <input type="hidden" name="action" value="settings">

+ 705 - 0
app/archive.php

@@ -0,0 +1,705 @@
+<?php
+/**
+ * Gallery ZIP archives: building them, and keeping them up to date.
+ *
+ * Why it works this way
+ * --------------------
+ * A gallery can hold hundreds of 8 MB originals, and the host caps
+ * max_execution_time at 60 s. Streaming a multi-gigabyte ZIP through PHP would
+ * need a request that stays alive for the whole download, so instead the archive
+ * is *built once into S3* and visitors are redirected to a presigned URL for it.
+ * The download then never touches the webhost at all — it is a plain S3 GET,
+ * resumable and immune to any server timeout.
+ *
+ * Building it is split into slices. archive_run_slice() copies as many photos as
+ * fit in a time budget and returns; progress is committed to a state file after
+ * every photo, so "build the archive" is just "run slices until finished". No
+ * single request ever approaches the 60 s cap, and nothing depends on
+ * set_time_limit() being allowed.
+ *
+ * Each photo is streamed S3 → buffer file → S3: the ZIP needs a local header
+ * immediately before the file's bytes, and multipart parts are atomic, so the
+ * bytes cannot be copied server-side with UploadPartCopy. The buffer file
+ * accumulates until it passes the 5 MB multipart minimum, then becomes one part.
+ *
+ * Interruptions
+ * -------------
+ * State is written with json_write() (tmp file + rename), so it is never half
+ * written — at worst it is one slice old and that slice replays. The rest is
+ * handled by ordering:
+ *
+ *   - mid photo   → every slice starts by truncating the buffer back to the last
+ *                   committed length, so a partial tail is dropped without
+ *                   needing the failure to have been caught.
+ *   - mid part    → the ETag is committed to state only after S3 accepts the
+ *                   part, and the buffer is truncated only after that. A crash
+ *                   anywhere in between re-uploads the same part number, which
+ *                   S3 accepts until the upload is completed.
+ *   - mid finish  → a retried CompleteMultipartUpload returns NoSuchUpload once
+ *                   it has already succeeded; that is treated as done after a
+ *                   HEAD confirms the object exists.
+ *   - abandoned   → the queue entry survives on disk, and a build with no
+ *                   progress for archive.abandon_hours is aborted and restarted.
+ *
+ * Staying current
+ * ---------------
+ * Every stored or deleted image marks its gallery dirty (archive_mark_dirty).
+ * A dirty gallery's download button is disabled until the rebuild lands, rather
+ * than handing out a ZIP that is missing the newest photos. Rebuilds run in the
+ * background: archive_kick() on a page render dispatches worker.php, which runs
+ * a slice and then dispatches its own successor, so one upload starts a chain
+ * that finishes unattended. See docs/ARCHITECTURE.md.
+ */
+
+declare(strict_types=1);
+
+/** Queue of galleries whose archive no longer matches their contents. */
+function archive_queue_file(): string
+{
+    return DATA_DIR . '/archive-queue.json';
+}
+
+/** Site-wide lock: exactly one archive worker runs at a time. */
+function archive_lock_file(): string
+{
+    return DATA_DIR . '/archive.lock';
+}
+
+/** Throttle marker so a busy site does not dispatch a worker per page view. */
+function archive_tick_file(): string
+{
+    return DATA_DIR . '/archive.tick';
+}
+
+/**
+ * Identity of a gallery's image set. A build records the hash it was made from;
+ * when the gallery's current hash differs, the archive is out of date.
+ */
+function archive_source_hash(array $gallery): string
+{
+    return sha1(implode("\n", array_column($gallery['images'] ?? [], 'key')));
+}
+
+/** Whether a gallery's archive is missing or no longer matches its images. */
+function archive_is_stale(array $gallery): bool
+{
+    $archive = $gallery['archive'] ?? null;
+    if (empty($archive['key'])) {
+        return true;
+    }
+    return ($archive['source_hash'] ?? '') !== archive_source_hash($gallery);
+}
+
+// ---------------------------------------------------------------------------
+// The queue
+// ---------------------------------------------------------------------------
+
+/**
+ * Flag a gallery for rebuilding. Called from gallery_append_image() and from
+ * image deletion, i.e. everywhere a gallery's contents can change.
+ *
+ * Uploads run in parallel (uploads.concurrency), so the queue is written through
+ * json_update()'s exclusive lock — three uploads finishing together must not
+ * drop each other's entries. Galleries without downloads enabled are skipped:
+ * queueing them would give the worker chain work it can never finish.
+ */
+function archive_mark_dirty(string $slug, ?array $gallery = null): void
+{
+    $gallery ??= gallery_load($slug);
+    if ($gallery === null || empty($gallery['downloads_enabled'])) {
+        return;
+    }
+    json_update(archive_queue_file(), function (array $queue) use ($slug): array {
+        $queue['galleries'][$slug] = time();
+        return $queue;
+    });
+}
+
+/** Drop a gallery from the queue (rebuilt, deleted, or downloads turned off). */
+function archive_unqueue(string $slug): void
+{
+    json_update(archive_queue_file(), function (array $queue) use ($slug): ?array {
+        if (!isset($queue['galleries'][$slug])) {
+            return null;   // nothing to do; leave the file untouched
+        }
+        unset($queue['galleries'][$slug]);
+        return $queue;
+    });
+}
+
+/**
+ * The next gallery due for a rebuild, or null if none has settled yet.
+ *
+ * A gallery is only rebuilt once it has been quiet for archive.settle_seconds.
+ * Thirty wedding guests uploading over an hour must not restart the build thirty
+ * times; the delay batches an upload burst into a single rebuild.
+ */
+function archive_next_due(): ?string
+{
+    $queue = json_read(archive_queue_file());
+    $settle = (int)config('archive.settle_seconds', 300);
+    foreach ($queue['galleries'] ?? [] as $slug => $dirtyAt) {
+        if (time() - (int)$dirtyAt >= $settle) {
+            return (string)$slug;
+        }
+    }
+    return null;
+}
+
+/**
+ * Seconds until the earliest queued gallery settles: 0 if one is due now, null
+ * if the queue is empty. The worker uses this to decide between doing a slice
+ * and waiting out the settle window.
+ */
+function archive_queue_wait(): ?int
+{
+    $queue = json_read(archive_queue_file());
+    $settle = (int)config('archive.settle_seconds', 300);
+    $wait = null;
+    foreach ($queue['galleries'] ?? [] as $dirtyAt) {
+        $due = max(0, $settle - (time() - (int)$dirtyAt));
+        $wait = $wait === null ? $due : min($wait, $due);
+    }
+    return $wait;
+}
+
+// ---------------------------------------------------------------------------
+// Build state
+// ---------------------------------------------------------------------------
+
+/**
+ * Start a fresh build: abandon anything in flight, open a new multipart upload,
+ * and write the initial state. Returns the state, or null if it cannot start.
+ *
+ * Content-Type and Content-Disposition are set on the multipart create, so S3
+ * stores them as the finished object's metadata and the presigned URL downloads
+ * as a properly named .zip with no extra signing work.
+ */
+function archive_start(string $slug, array $gallery): ?array
+{
+    if (empty($gallery['images'])) {
+        return null;   // nothing to archive
+    }
+    archive_abort($slug);
+
+    $filename = safe_filename(($gallery['title'] ?: $slug) . '.zip', $slug);
+    $key = s3_gallery_prefix($slug) . '/archive/' . random_token(6) . '-' . $filename;
+
+    $uploadId = s3_mpu_create($key, 'application/zip', 'attachment; filename="' . $filename . '"');
+    if ($uploadId === null) {
+        return null;
+    }
+
+    $state = [
+        'key'         => $key,
+        'upload_id'   => $uploadId,
+        'source_hash' => archive_source_hash($gallery),
+        'total'       => count($gallery['images']),
+        'next_index'  => 0,
+        // Archive length so far, and how much of it S3 already has. The
+        // difference is exactly what the buffer file holds.
+        'offset'      => 0,
+        'uploaded'    => 0,
+        'part_number' => 1,
+        'parts'       => [],
+        'entries'     => [],
+        'names'       => [],   // filename dedupe map, carried across slices
+        'slowest'     => 5.0,  // seconds; grows to the slowest photo seen
+        'started_at'  => time(),
+    ];
+    json_write(gallery_archive_file($slug), $state);
+    @unlink(gallery_archive_buffer($slug));
+    return $state;
+}
+
+/**
+ * Abandon an in-flight build. Aborting the multipart upload matters: S3 keeps
+ * (and bills for) the parts of an incomplete upload indefinitely.
+ */
+function archive_abort(string $slug): void
+{
+    $state = json_read(gallery_archive_file($slug));
+    if (!empty($state['upload_id']) && !empty($state['key'])) {
+        s3_mpu_abort((string)$state['key'], (string)$state['upload_id']);
+    }
+    @unlink(gallery_archive_file($slug));
+    @unlink(gallery_archive_buffer($slug));
+}
+
+/** Delete a gallery's finished archive from S3 and forget it. */
+function archive_delete(string $slug): void
+{
+    archive_abort($slug);
+    $gallery = gallery_load($slug);
+    if ($gallery !== null && !empty($gallery['archive']['key'])) {
+        s3_delete((string)$gallery['archive']['key']);
+    }
+    json_update(gallery_file($slug), function (array $g): ?array {
+        if ($g === [] || !isset($g['archive'])) {
+            return null;
+        }
+        unset($g['archive']);
+        return $g;
+    });
+    archive_unqueue($slug);
+}
+
+// ---------------------------------------------------------------------------
+// The slice
+// ---------------------------------------------------------------------------
+
+/**
+ * Copy as many of a gallery's photos into its archive as fit in $budget seconds.
+ *
+ * Returns ['done' => int, 'total' => int, 'finished' => bool, 'size' => ?int,
+ *          'error' => ?string]. Call again to continue; all progress is on disk.
+ */
+function archive_run_slice(string $slug, ?float $budget = null): array
+{
+    $started = microtime(true);
+    $budget ??= (float)config('archive.step_seconds', 25);
+
+    $gallery = gallery_load($slug);
+    if ($gallery === null) {
+        archive_unqueue($slug);
+        return archive_result(0, 0, false, null, 'Gallery no longer exists');
+    }
+
+    $stateFile = gallery_archive_file($slug);
+    $state = json_read($stateFile);
+    $abandoned = $state !== []
+        && time() - (int)($state['started_at'] ?? 0) > (int)config('archive.abandon_hours', 24) * 3600;
+
+    // Restart whenever the gallery has changed under an in-flight build, or the
+    // build has been stalled long enough to be considered dead.
+    if ($state === [] || $abandoned || ($state['source_hash'] ?? '') !== archive_source_hash($gallery)) {
+        $state = archive_start($slug, $gallery);
+        if ($state === null) {
+            archive_unqueue($slug);
+            return archive_result(0, 0, false, null, 'Cannot start archive (empty gallery or S3 refused)');
+        }
+    }
+
+    $images = $gallery['images'];
+    $bufferPath = gallery_archive_buffer($slug);
+    $fh = fopen($bufferPath, 'c+b');
+    if ($fh === false) {
+        return archive_result((int)$state['next_index'], (int)$state['total'], false, null, 'Cannot open archive buffer');
+    }
+    flock($fh, LOCK_EX);
+
+    // Recovery: drop anything written past the last committed position. Doing
+    // this unconditionally means a hard kill needs no cleanup of its own.
+    ftruncate($fh, (int)$state['offset'] - (int)$state['uploaded']);
+    fseek($fh, 0, SEEK_END);
+
+    $partMin = (int)config('archive.part_min_bytes', 5 * 1024 * 1024);
+    $mtime = strtotime((string)($gallery['created_at'] ?? '')) ?: time();
+    $error = null;
+    $processed = 0;
+
+    while ($state['next_index'] < $state['total']) {
+        // Check the clock before starting a photo, never during one, and leave
+        // room for one that runs as long as the slowest seen so far.
+        //
+        // Always do at least one photo, whatever the estimate says. A gallery
+        // whose photos each take longer than the whole budget must still creep
+        // forward one photo per slice; refusing to start would leave the build
+        // stuck for ever, which is far worse than a slice that overruns.
+        if ($processed > 0 && microtime(true) - $started + $state['slowest'] * 1.5 >= $budget) {
+            break;
+        }
+
+        $photoStart = microtime(true);
+        $image = $images[$state['next_index']];
+
+        // Reserve the name in a copy: a photo that fails below is retried by the
+        // next slice, and a name left registered by the failed attempt would
+        // make the retry rename itself to "… (2)".
+        $names = $state['names'];
+        $name = zip_dedupe_name(
+            safe_filename((string)($image['name'] ?? 'photo.jpg'), 'photo'),
+            $names
+        );
+
+        $entryOffset = (int)$state['offset'];
+        $header = zip_local_header($name, $mtime);
+        fwrite($fh, $header);
+
+        // One pass: bytes go to the buffer and through the CRC at the same time,
+        // so an original never has to be held in memory or read back.
+        $crcContext = hash_init('crc32b');
+        [$status, $bytes] = s3_get_stream((string)$image['key'], function (string $chunk) use ($fh, $crcContext): void {
+            hash_update($crcContext, $chunk);
+            fwrite($fh, $chunk);
+        });
+
+        if ($status < 200 || $status >= 300) {
+            // Undo this photo entirely and stop; the next slice retries it.
+            ftruncate($fh, $entryOffset - (int)$state['uploaded']);
+            fseek($fh, 0, SEEK_END);
+            $error = 'S3 returned HTTP ' . $status . ' for ' . ($image['name'] ?? $image['key']);
+            break;
+        }
+
+        // The real size and CRC are only known now, so patch them into the
+        // header written above. Using the streamed byte count rather than the
+        // recorded one also heals a gallery whose stored size was ever wrong.
+        $crc = (int)hexdec(hash_final($crcContext));
+        zip_patch_local_header($fh, $entryOffset - (int)$state['uploaded'], $name, $crc, $bytes);
+
+        $state['names'] = $names;
+        $state['entries'][] = [
+            'name'   => $name,
+            'crc'    => $crc,
+            'size'   => $bytes,
+            'offset' => $entryOffset,
+            'mtime'  => $mtime,
+        ];
+        $state['offset'] = $entryOffset + strlen($header) + $bytes;
+        $state['next_index']++;
+        $state['slowest'] = max((float)$state['slowest'], microtime(true) - $photoStart);
+        $processed++;
+
+        if ((int)$state['offset'] - (int)$state['uploaded'] >= $partMin) {
+            $error = archive_flush_part($state, $fh, $bufferPath, false);
+            if ($error !== null) {
+                break;
+            }
+        }
+        json_write($stateFile, $state);
+    }
+
+    // Everything copied: append the central directory and close the upload.
+    $finished = false;
+    if ($error === null && $state['next_index'] >= $state['total']) {
+        [$finished, $error] = archive_finish($slug, $state, $fh, $bufferPath);
+    }
+
+    json_write($stateFile, $state);
+    flock($fh, LOCK_UN);
+    fclose($fh);
+
+    if ($finished) {
+        @unlink($stateFile);
+        @unlink($bufferPath);
+    }
+
+    return archive_result(
+        (int)$state['next_index'],
+        (int)$state['total'],
+        $finished,
+        $finished ? (int)$state['offset'] : null,
+        $error
+    );
+}
+
+/**
+ * Send the buffered bytes to S3 as the next multipart part.
+ *
+ * The order here is what makes an interrupted build safe: S3 accepts the part,
+ * then the ETag is committed to state, and only then is the buffer cleared. A
+ * crash before the commit re-uploads the same part number with the same bytes,
+ * which S3 allows until the upload is completed.
+ *
+ * Returns an error message, or null on success.
+ *
+ * @param resource $fh
+ */
+function archive_flush_part(array &$state, $fh, string $bufferPath, bool $isLast): ?string
+{
+    fflush($fh);
+    if (!$isLast && (int)$state['offset'] - (int)$state['uploaded'] === 0) {
+        return null;   // nothing pending
+    }
+
+    $etag = s3_mpu_upload_part(
+        (string)$state['key'],
+        (string)$state['upload_id'],
+        (int)$state['part_number'],
+        $bufferPath
+    );
+    if ($etag === null) {
+        return 'S3 rejected part ' . $state['part_number'];
+    }
+
+    $state['parts'][] = ['n' => (int)$state['part_number'], 'etag' => $etag];
+    $state['part_number']++;
+    $state['uploaded'] = (int)$state['offset'];
+
+    ftruncate($fh, 0);
+    fseek($fh, 0, SEEK_END);
+    return null;
+}
+
+/**
+ * Write the central directory, upload the last part and complete the multipart
+ * upload, then record the archive on the gallery.
+ *
+ * Returns [finished, error].
+ *
+ * @param resource $fh
+ */
+function archive_finish(string $slug, array &$state, $fh, string $bufferPath): array
+{
+    // The central directory is built now, from the real sizes and offsets, so
+    // it uses ZIP64 fields only where a value actually overflows 32 bits.
+    $directory = '';
+    foreach ($state['entries'] as $entry) {
+        $directory .= zip_central_entry($entry);
+    }
+    $trailer = $directory . zip_end_of_central_directory(
+        count($state['entries']),
+        strlen($directory),
+        (int)$state['offset']
+    );
+    fwrite($fh, $trailer);
+    $state['offset'] = (int)$state['offset'] + strlen($trailer);
+
+    // The 5 MB minimum does not apply to the final part, which is what lets a
+    // gallery of small files work with the same buffering scheme.
+    $error = archive_flush_part($state, $fh, $bufferPath, true);
+    if ($error !== null) {
+        return [false, $error];
+    }
+
+    [$ok, $body] = s3_mpu_complete((string)$state['key'], (string)$state['upload_id'], $state['parts']);
+    if (!$ok) {
+        // A completed upload no longer exists; if the object is there, an
+        // earlier attempt succeeded and only the state write was lost.
+        if (str_contains($body, 'NoSuchUpload') && s3_head((string)$state['key']) !== null) {
+            $ok = true;
+        }
+    }
+    if (!$ok) {
+        return [false, 'S3 could not complete the archive upload'];
+    }
+
+    $summary = [
+        'key'         => (string)$state['key'],
+        'size'        => (int)$state['offset'],
+        'count'       => count($state['entries']),
+        'built_at'    => date('Y-m-d H:i:s'),
+        'source_hash' => (string)$state['source_hash'],
+    ];
+
+    // json_update, not gallery_save: an upload finishing right now must not be
+    // overwritten by a gallery this function read minutes ago.
+    $previousKey = null;
+    json_update(gallery_file($slug), function (array $g) use ($summary, &$previousKey): ?array {
+        if ($g === []) {
+            return null;   // deleted mid-build
+        }
+        $previousKey = $g['archive']['key'] ?? null;
+        $g['archive'] = $summary;
+        return $g;
+    });
+
+    if ($previousKey !== null && $previousKey !== $summary['key']) {
+        s3_delete((string)$previousKey);
+    }
+
+    // Leave the gallery queued if it changed while this build was running — the
+    // archive just written is already out of date and needs another pass.
+    $current = gallery_load($slug);
+    if ($current === null || !archive_is_stale($current)) {
+        archive_unqueue($slug);
+    }
+
+    return [true, null];
+}
+
+/** Uniform slice/step result shape, shared by the worker and the admin API. */
+function archive_result(int $done, int $total, bool $finished, ?int $size, ?string $error): array
+{
+    return [
+        'done'     => $done,
+        'total'    => $total,
+        'finished' => $finished,
+        'size'     => $size,
+        'error'    => $error,
+    ];
+}
+
+/**
+ * What the admin page shows for a gallery: whether an archive exists, whether it
+ * is current, and how far any in-flight build has got.
+ */
+function archive_status(array $gallery): array
+{
+    $slug = (string)$gallery['slug'];
+    $archive = $gallery['archive'] ?? null;
+    $state = json_read(gallery_archive_file($slug));
+    $queue = json_read(archive_queue_file());
+
+    return [
+        'archive'  => $archive,
+        'stale'    => archive_is_stale($gallery),
+        'building' => $state !== [],
+        'done'     => (int)($state['next_index'] ?? 0),
+        'total'    => (int)($state['total'] ?? count($gallery['images'] ?? [])),
+        'queued'   => isset($queue['galleries'][$slug]),
+        'due_in'   => isset($queue['galleries'][$slug])
+            ? max(0, (int)config('archive.settle_seconds', 300) - (time() - (int)$queue['galleries'][$slug]))
+            : null,
+    ];
+}
+
+// ---------------------------------------------------------------------------
+// Background execution
+// ---------------------------------------------------------------------------
+
+/** Shared secret authenticating the self-dispatched worker requests. */
+function archive_worker_key(): string
+{
+    $file = DATA_DIR . '/worker-key.json';
+    $data = json_read($file);
+    if (empty($data['key'])) {
+        $data = ['key' => random_token(32)];
+        json_write($file, $data);
+    }
+    return (string)$data['key'];
+}
+
+/**
+ * URL of worker.php on this installation.
+ *
+ * site.base_url is preferred when it has been filled in, because it does not
+ * depend on the request's Host header. Otherwise the URL is derived from the
+ * current request, mapping APP_ROOT against DOCUMENT_ROOT so an app installed in
+ * a subdirectory still resolves.
+ */
+function archive_worker_url(): ?string
+{
+    $query = '/worker.php?key=' . rawurlencode(archive_worker_key());
+
+    $configured = rtrim((string)config('site.base_url', ''), '/');
+    if ($configured !== '' && !str_contains($configured, 'example.com')) {
+        return $configured . $query;
+    }
+
+    $host = (string)($_SERVER['HTTP_HOST'] ?? '');
+    $root = rtrim(str_replace('\\', '/', (string)($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
+    $app  = str_replace('\\', '/', APP_ROOT);
+    if ($host === '' || $root === '' || !str_starts_with($app, $root)) {
+        return null;
+    }
+    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+    return $scheme . '://' . $host . rtrim(substr($app, strlen($root)), '/') . $query;
+}
+
+/**
+ * Fire a request at worker.php and hang up without waiting for it. The worker
+ * sets ignore_user_abort(), so it runs its slice regardless.
+ *
+ * A timeout is the expected, successful outcome here — it means the request was
+ * delivered and the worker is busy with it.
+ */
+function archive_dispatch(int $timeoutMs = 1000): bool
+{
+    $url = archive_worker_url();
+    if ($url === null || !function_exists('curl_init')) {
+        return false;
+    }
+    $ch = curl_init($url);
+    curl_setopt_array($ch, [
+        CURLOPT_RETURNTRANSFER    => true,
+        CURLOPT_NOSIGNAL          => true,
+        CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
+        CURLOPT_TIMEOUT_MS        => $timeoutMs,
+    ]);
+    curl_exec($ch);
+    $errno = curl_errno($ch);
+    return $errno === 0 || $errno === CURLE_OPERATION_TIMEOUTED;
+}
+
+/**
+ * Take the site-wide worker lock, or report that someone else holds it.
+ * Returns the lock handle (which must stay open for the lock to hold) or null.
+ *
+ * $waitSeconds polls rather than blocking outright, so an admin asking for a
+ * rebuild can wait out a background worker's short settle-sleep instead of
+ * failing the moment it finds the lock taken — while a background worker, which
+ * has nothing to wait for, passes 0 and steps aside immediately.
+ *
+ * @return resource|null
+ */
+function archive_lock(int $waitSeconds = 0)
+{
+    $fh = fopen(archive_lock_file(), 'c');
+    if ($fh === false) {
+        return null;
+    }
+    $deadline = time() + $waitSeconds;
+    do {
+        if (flock($fh, LOCK_EX | LOCK_NB)) {
+            return $fh;
+        }
+        if (time() < $deadline) {
+            sleep(1);
+        }
+    } while (time() < $deadline);
+
+    fclose($fh);
+    return null;
+}
+
+/**
+ * Run one slice for whichever gallery is due, under the worker lock.
+ * Returns the slice result, or null if nothing was due or another worker holds
+ * the lock.
+ */
+function archive_run_due(): ?array
+{
+    $lock = archive_lock();
+    if ($lock === null) {
+        return null;
+    }
+    try {
+        $slug = archive_next_due();
+        return $slug === null ? null : archive_run_slice($slug);
+    } finally {
+        flock($lock, LOCK_UN);
+        fclose($lock);
+    }
+}
+
+/**
+ * Called at the end of every public page render. Decides, as cheaply as
+ * possible, whether any background work is pending and gets it moving.
+ *
+ * The page is flushed to the visitor before anything slow happens, so neither
+ * the dispatch nor the inline fallback can delay it. The fallback matters on
+ * hosts that cannot make an HTTP request to themselves: there, progress needs
+ * one page view per slice instead of running on its own.
+ */
+function archive_kick(): void
+{
+    $tick = archive_tick_file();
+    if (is_file($tick) && time() - (int)filemtime($tick) < 30) {
+        return;   // dispatched recently; do not spend anything on this request
+    }
+
+    $queue = json_read(archive_queue_file());
+    if (empty($queue['galleries'])) {
+        return;
+    }
+    @touch($tick);
+
+    if (!function_exists('fastcgi_finish_request')) {
+        // Cannot detach: keep the delay to the visitor as short as possible.
+        archive_dispatch(200);
+        return;
+    }
+
+    @fastcgi_finish_request();
+    if (archive_dispatch(2000)) {
+        return;
+    }
+    // No self-dispatch on this host. The visitor already has the page, so this
+    // process can do the work itself.
+    if (session_status() === PHP_SESSION_ACTIVE) {
+        session_write_close();
+    }
+    archive_run_due();
+}

+ 18 - 0
app/bootstrap.php

@@ -23,6 +23,8 @@ 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/s3.php';
 require APP_ROOT . '/app/s3.php';
+require APP_ROOT . '/app/zip.php';
+require APP_ROOT . '/app/archive.php';
 require APP_ROOT . '/app/markdown.php';
 require APP_ROOT . '/app/markdown.php';
 require APP_ROOT . '/app/partials.php';
 require APP_ROOT . '/app/partials.php';
 
 
@@ -59,6 +61,22 @@ function e(?string $s): string
     return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
     return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
 }
 }
 
 
+/**
+ * Byte count as something a client can judge at a glance. Gallery archives run
+ * to gigabytes, so the size belongs on the download button itself.
+ */
+function human_bytes(int $bytes): string
+{
+    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+    $i = 0;
+    $value = (float)$bytes;
+    while ($value >= 1024 && $i < count($units) - 1) {
+        $value /= 1024;
+        $i++;
+    }
+    return ($value >= 10 || $i === 0 ? round($value) : round($value, 1)) . ' ' . $units[$i];
+}
+
 /** Start the session with hardened cookie settings (idempotent). */
 /** Start the session with hardened cookie settings (idempotent). */
 function session_boot(): void
 function session_boot(): void
 {
 {

+ 4 - 0
app/partials.php

@@ -47,6 +47,10 @@ function public_footer(): void
 </body>
 </body>
 </html>
 </html>
 <?php
 <?php
+    // Last thing on every public page: nudge the gallery-archive worker along if
+    // anything is queued. Cheap when there is nothing to do, and it flushes the
+    // page to the visitor before doing anything slow. See app/archive.php.
+    archive_kick();
 }
 }
 
 
 function admin_header(string $title, string $active = ''): void
 function admin_header(string $title, string $active = ''): void

+ 289 - 78
app/s3.php

@@ -203,6 +203,109 @@ function s3_is_transient(int $status): bool
     return $status === 0 || $status === 408 || $status === 429 || $status >= 500;
     return $status === 0 || $status === 408 || $status === 429 || $status >= 500;
 }
 }
 
 
+/**
+ * Canonical query string: keys sorted, both sides percent-encoded. The same
+ * string goes into the signature and onto the wire, so the two cannot drift.
+ */
+function s3_canonical_query(array $query): string
+{
+    ksort($query);
+    $parts = [];
+    foreach ($query as $name => $value) {
+        $parts[] = rawurlencode((string)$name) . '=' . rawurlencode((string)$value);
+    }
+    return implode('&', $parts);
+}
+
+/** Full request URL for a key, with an optional canonical query string. */
+function s3_url(string $key, array $query = []): string
+{
+    $canonicalQuery = s3_canonical_query($query);
+    return s3_base_url() . s3_canonical_uri($key) . ($canonicalQuery !== '' ? '?' . $canonicalQuery : '');
+}
+
+/**
+ * SigV4 header authentication — the shared core behind every server-side
+ * request (PUT, DELETE, GET, and the multipart calls). Presigning stays
+ * separate (s3_presign_query) because it carries the signature in the query
+ * string instead, and is verified against the official AWS example vectors.
+ *
+ * $signable are extra headers to both sign and send, e.g. the Content-Type and
+ * Content-Disposition that a multipart create stores on the finished object.
+ * Host, x-amz-content-sha256 and x-amz-date are always included.
+ *
+ * Returns the header lines for CURLOPT_HTTPHEADER.
+ */
+function s3_auth_headers(
+    string $method,
+    string $canonicalUri,
+    string $canonicalQuery,
+    string $payloadHash,
+    array $signable = []
+): array {
+    $amzDate = gmdate('Ymd\THis\Z');
+    $date = substr($amzDate, 0, 8);
+    $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
+
+    // Signed headers must be lowercase and sorted; values trimmed.
+    $headers = array_change_key_case($signable, CASE_LOWER);
+    $headers['host'] = s3_host();
+    $headers['x-amz-content-sha256'] = $payloadHash;
+    $headers['x-amz-date'] = $amzDate;
+    ksort($headers);
+
+    $canonicalHeaders = '';
+    foreach ($headers as $name => $value) {
+        $canonicalHeaders .= $name . ':' . trim((string)$value) . "\n";
+    }
+    $signedHeaders = implode(';', array_keys($headers));
+
+    // $canonicalHeaders already ends in "\n", so implode's separator supplies
+    // the blank line the canonical request format requires after it.
+    $canonicalRequest = implode("\n", [
+        strtoupper($method),
+        $canonicalUri,
+        $canonicalQuery,
+        $canonicalHeaders,
+        $signedHeaders,
+        $payloadHash,
+    ]);
+
+    $stringToSign = implode("\n", [
+        'AWS4-HMAC-SHA256',
+        $amzDate,
+        $scope,
+        hash('sha256', $canonicalRequest),
+    ]);
+
+    $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
+
+    $lines = ['Authorization: AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
+        . ', SignedHeaders=' . $signedHeaders
+        . ', Signature=' . $signature];
+    foreach ($headers as $name => $value) {
+        if ($name !== 'host') {   // curl derives Host from the URL itself
+            $lines[] = $name . ': ' . $value;
+        }
+    }
+    return $lines;
+}
+
+/**
+ * Collect response headers into $into (lowercased names) as curl receives them.
+ * Used for the ETag a multipart part upload returns.
+ */
+function s3_header_collector(array &$into): callable
+{
+    return function ($ch, string $line) use (&$into): int {
+        $parts = explode(':', $line, 2);
+        if (count($parts) === 2) {
+            $into[strtolower(trim($parts[0]))] = trim($parts[1]);
+        }
+        return strlen($line);
+    };
+}
+
 /**
 /**
  * Stream a local file to S3 with a signed PUT (header auth). The payload is sent
  * Stream a local file to S3 with a signed PUT (header auth). The payload is sent
  * as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
  * as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
@@ -241,44 +344,32 @@ function s3_put_file(string $key, string $filePath, string $contentType = 'appli
 /**
 /**
  * One signed PUT attempt streaming from an open, positioned file handle.
  * One signed PUT attempt streaming from an open, positioned file handle.
  *
  *
+ * $query lets a multipart part upload reuse this exact streaming path
+ * (?partNumber=N&uploadId=…); $contentType is skipped when empty, because a
+ * part carries no type of its own.
+ *
+ * Returns [httpStatus, responseBody, responseHeaders].
+ *
  * @param resource $fh
  * @param resource $fh
  */
  */
-function s3_put_stream(string $key, $fh, int $size, string $contentType): array
+function s3_put_stream(string $key, $fh, int $size, string $contentType, array $query = []): array
 {
 {
-    $host = s3_host();
-    $amzDate = gmdate('Ymd\THis\Z');
-    $date = substr($amzDate, 0, 8);
-    $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
     $canonicalUri = s3_canonical_uri($key);
     $canonicalUri = s3_canonical_uri($key);
+    $canonicalQuery = s3_canonical_query($query);
     $payloadHash = 'UNSIGNED-PAYLOAD';
     $payloadHash = 'UNSIGNED-PAYLOAD';
 
 
-    $canonicalRequest = implode("\n", [
-        'PUT',
-        $canonicalUri,
-        '',
-        'host:' . $host,
-        'x-amz-content-sha256:' . $payloadHash,
-        'x-amz-date:' . $amzDate,
-        '',
-        'host;x-amz-content-sha256;x-amz-date',
-        $payloadHash,
-    ]);
-
-    $stringToSign = implode("\n", [
-        'AWS4-HMAC-SHA256',
-        $amzDate,
-        $scope,
-        hash('sha256', $canonicalRequest),
-    ]);
-
-    $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
-    $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
-        . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
-        . ', Signature=' . $signature;
+    // Content-Type is sent but deliberately not signed, matching how uploads
+    // have always been signed here.
+    $headers = s3_auth_headers('PUT', $canonicalUri, $canonicalQuery, $payloadHash);
+    if ($contentType !== '') {
+        $headers[] = 'Content-Type: ' . $contentType;
+    }
+    $headers[] = 'Expect:';                // skip 100-continue round-trip
 
 
+    $responseHeaders = [];
     $ch = s3_curl();
     $ch = s3_curl();
     curl_setopt_array($ch, [
     curl_setopt_array($ch, [
-        CURLOPT_URL            => s3_base_url() . $canonicalUri,
+        CURLOPT_URL            => s3_url($key, $query),
         CURLOPT_UPLOAD         => true,   // sets method to PUT and streams CURLOPT_INFILE
         CURLOPT_UPLOAD         => true,   // sets method to PUT and streams CURLOPT_INFILE
         CURLOPT_INFILE         => $fh,
         CURLOPT_INFILE         => $fh,
         CURLOPT_INFILESIZE     => $size,
         CURLOPT_INFILESIZE     => $size,
@@ -291,71 +382,49 @@ function s3_put_stream(string $key, $fh, int $size, string $contentType): array
         CURLOPT_LOW_SPEED_LIMIT => 1024,
         CURLOPT_LOW_SPEED_LIMIT => 1024,
         CURLOPT_LOW_SPEED_TIME  => 120,
         CURLOPT_LOW_SPEED_TIME  => 120,
         CURLOPT_TCP_NODELAY    => true,
         CURLOPT_TCP_NODELAY    => true,
-        CURLOPT_HTTPHEADER     => [
-            'Authorization: ' . $authorization,
-            'x-amz-content-sha256: ' . $payloadHash,
-            'x-amz-date: ' . $amzDate,
-            'Content-Type: ' . $contentType,
-            'Expect:',                     // skip 100-continue round-trip
-        ],
+        CURLOPT_HEADERFUNCTION => s3_header_collector($responseHeaders),
+        CURLOPT_HTTPHEADER     => $headers,
     ]);
     ]);
     $body = curl_exec($ch);
     $body = curl_exec($ch);
     $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
     $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
-    return [$status, (string)$body];
+    return [$status, (string)$body, $responseHeaders];
 }
 }
 
 
 /**
 /**
- * Server-side signed request (header auth). Used for DELETE.
- * Returns [httpStatus, responseBody].
+ * Server-side signed request with a small (or empty) body: DELETE, HEAD, and
+ * the multipart create/complete/abort calls.
+ *
+ * The body is hashed in full rather than sent as UNSIGNED-PAYLOAD, which S3
+ * requires for the multipart XML calls; it is only ever a few hundred bytes.
+ * $signable adds headers that must be signed as well as sent.
+ *
+ * Returns [httpStatus, responseBody, responseHeaders].
  */
  */
-function s3_request(string $method, string $key): array
+function s3_request(string $method, string $key, array $query = [], string $body = '', array $signable = []): array
 {
 {
-    $host = s3_host();
-    $amzDate = gmdate('Ymd\THis\Z');
-    $date = substr($amzDate, 0, 8);
-    $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
     $canonicalUri = s3_canonical_uri($key);
     $canonicalUri = s3_canonical_uri($key);
-    $payloadHash = hash('sha256', '');
-
-    $canonicalRequest = implode("\n", [
-        strtoupper($method),
-        $canonicalUri,
-        '', // no query string
-        'host:' . $host,
-        'x-amz-content-sha256:' . $payloadHash,
-        'x-amz-date:' . $amzDate,
-        '',
-        'host;x-amz-content-sha256;x-amz-date',
-        $payloadHash,
-    ]);
-
-    $stringToSign = implode("\n", [
-        'AWS4-HMAC-SHA256',
-        $amzDate,
-        $scope,
-        hash('sha256', $canonicalRequest),
-    ]);
+    $canonicalQuery = s3_canonical_query($query);
+    $payloadHash = hash('sha256', $body);
 
 
-    $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
-    $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
-        . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
-        . ', Signature=' . $signature;
+    $headers = s3_auth_headers($method, $canonicalUri, $canonicalQuery, $payloadHash, $signable);
+    if ($body !== '') {
+        $headers[] = 'Content-Type: application/xml';
+    }
 
 
+    $responseHeaders = [];
     $ch = s3_curl();
     $ch = s3_curl();
     curl_setopt_array($ch, [
     curl_setopt_array($ch, [
-        CURLOPT_URL            => s3_base_url() . $canonicalUri,
+        CURLOPT_URL            => s3_url($key, $query),
         CURLOPT_CUSTOMREQUEST  => strtoupper($method),
         CURLOPT_CUSTOMREQUEST  => strtoupper($method),
         CURLOPT_RETURNTRANSFER => true,
         CURLOPT_RETURNTRANSFER => true,
         CURLOPT_TIMEOUT        => 30,
         CURLOPT_TIMEOUT        => 30,
-        CURLOPT_HTTPHEADER     => [
-            'Authorization: ' . $authorization,
-            'x-amz-content-sha256: ' . $payloadHash,
-            'x-amz-date: ' . $amzDate,
-        ],
-    ]);
-    $body = curl_exec($ch);
+        CURLOPT_NOBODY         => strtoupper($method) === 'HEAD',
+        CURLOPT_HEADERFUNCTION => s3_header_collector($responseHeaders),
+        CURLOPT_HTTPHEADER     => $headers,
+    ] + ($body !== '' ? [CURLOPT_POSTFIELDS => $body] : []));
+    $response = curl_exec($ch);
     $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
     $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
-    return [$status, (string)$body];
+    return [$status, (string)$response, $responseHeaders];
 }
 }
 
 
 /** Delete one object. S3 returns 204 for success and for already-gone keys. */
 /** Delete one object. S3 returns 204 for success and for already-gone keys. */
@@ -365,7 +434,146 @@ function s3_delete(string $key): bool
     return $status === 204 || $status === 200 || $status === 404;
     return $status === 204 || $status === 200 || $status === 404;
 }
 }
 
 
-/** Delete every S3 object referenced by a gallery (originals + thumbs). */
+/** Object metadata, or null if it does not exist. */
+function s3_head(string $key): ?array
+{
+    [$status, , $headers] = s3_request('HEAD', $key);
+    return $status === 200 ? $headers : null;
+}
+
+// ---------------------------------------------------------------------------
+// Downloading and multipart uploading — used by the gallery archive builder
+// (app/archive.php) to move objects back out of S3 and into a ZIP.
+// ---------------------------------------------------------------------------
+
+/**
+ * GET an object, handing each chunk to $onChunk as it arrives. Nothing is
+ * buffered, so an object far larger than memory_limit streams through fine.
+ *
+ * $onChunk is called only once the response is known to be a success — an error
+ * response body is XML, and feeding that to the caller would silently corrupt
+ * whatever it is writing.
+ *
+ * One attempt only: a caller that has already written part of the object
+ * somewhere has to undo that itself before retrying, so the retry decision
+ * belongs to it. Returns [httpStatus, bytesDelivered].
+ */
+function s3_get_stream(string $key, callable $onChunk): array
+{
+    $bytes = 0;
+    $ch = s3_curl();
+    curl_setopt_array($ch, [
+        CURLOPT_URL             => s3_url($key),
+        CURLOPT_HTTPGET         => true,
+        CURLOPT_CONNECTTIMEOUT  => 30,
+        CURLOPT_TIMEOUT         => 0,      // no cap: originals can be large
+        CURLOPT_LOW_SPEED_LIMIT => 1024,   // give up on a stalled socket
+        CURLOPT_LOW_SPEED_TIME  => 120,
+        CURLOPT_TCP_NODELAY     => true,
+        CURLOPT_HTTPHEADER      => s3_auth_headers('GET', s3_canonical_uri($key), '', hash('sha256', '')),
+        CURLOPT_WRITEFUNCTION   => function ($ch, string $chunk) use ($onChunk, &$bytes): int {
+            $length = strlen($chunk);
+            $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+            if ($status >= 200 && $status < 300) {
+                $onChunk($chunk);
+                $bytes += $length;
+            }
+            return $length;   // consume the error body too, or curl aborts
+        },
+    ]);
+    curl_exec($ch);
+    return [(int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $bytes];
+}
+
+/**
+ * Begin a multipart upload. Content-Type and Content-Disposition are signed and
+ * stored here, on the create — S3 keeps them as the finished object's metadata
+ * and returns them on GET, which is what makes the presigned archive URL
+ * download as a named .zip without any extra signing machinery.
+ *
+ * Returns the upload id, or null if S3 refused.
+ */
+function s3_mpu_create(string $key, string $contentType, string $disposition): ?string
+{
+    [$status, $body] = s3_request('POST', $key, ['uploads' => ''], '', [
+        'content-type'        => $contentType,
+        'content-disposition' => $disposition,
+    ]);
+    if ($status < 200 || $status >= 300) {
+        return null;
+    }
+    // A regex rather than ext-simplexml: this app ships with no dependencies,
+    // and the response is a fixed, tiny document.
+    return preg_match('#<UploadId>(.*?)</UploadId>#s', $body, $m) ? $m[1] : null;
+}
+
+/**
+ * Upload one part from a local file. Parts must be at least 5 MB except the
+ * last one, and a part number may be re-uploaded until the upload is completed
+ * — which is what makes an interrupted build safe to resume.
+ *
+ * Returns the part's ETag (quoted, as S3 sends it), or null on failure.
+ */
+function s3_mpu_upload_part(string $key, string $uploadId, int $partNumber, string $filePath, int $attempts = 3): ?string
+{
+    $fh = @fopen($filePath, 'rb');
+    if ($fh === false) {
+        return null;
+    }
+    $size = (int)filesize($filePath);
+    $query = ['partNumber' => (string)$partNumber, 'uploadId' => $uploadId];
+
+    $etag = null;
+    for ($try = 1; $try <= $attempts; $try++) {
+        rewind($fh);
+        [$status, , $headers] = s3_put_stream($key, $fh, $size, '', $query);
+        if ($status >= 200 && $status < 300) {
+            $etag = $headers['etag'] ?? null;
+            break;
+        }
+        if (!s3_is_transient($status) || $try === $attempts) {
+            break;
+        }
+        usleep(250000 * $try); // 0.25s, then 0.5s
+    }
+    fclose($fh);
+    return $etag;
+}
+
+/**
+ * Finish a multipart upload. $parts is [['n' => int, 'etag' => string], …] in
+ * ascending part order.
+ *
+ * S3 can report failure inside a 200 response here (it streams whitespace while
+ * assembling, then appends the real result), so the body is checked too.
+ * Returns [ok, responseBody] — the body lets the caller distinguish a
+ * NoSuchUpload, which means "already completed", from a real error.
+ */
+function s3_mpu_complete(string $key, string $uploadId, array $parts): array
+{
+    $xml = '<CompleteMultipartUpload>';
+    foreach ($parts as $part) {
+        $xml .= '<Part><PartNumber>' . (int)$part['n'] . '</PartNumber>'
+              . '<ETag>' . htmlspecialchars((string)$part['etag'], ENT_XML1) . '</ETag></Part>';
+    }
+    $xml .= '</CompleteMultipartUpload>';
+
+    [$status, $body] = s3_request('POST', $key, ['uploadId' => $uploadId], $xml);
+    $ok = $status >= 200 && $status < 300 && !str_contains($body, '<Error>');
+    return [$ok, $body];
+}
+
+/**
+ * Abandon a multipart upload and release the parts S3 is storing (and billing)
+ * for it. 404 counts as success: the upload is gone either way.
+ */
+function s3_mpu_abort(string $key, string $uploadId): bool
+{
+    [$status] = s3_request('DELETE', $key, ['uploadId' => $uploadId]);
+    return $status === 204 || $status === 200 || $status === 404;
+}
+
+/** Delete every S3 object referenced by a gallery (originals, thumbs, archive). */
 function s3_delete_gallery_objects(array $gallery): void
 function s3_delete_gallery_objects(array $gallery): void
 {
 {
     foreach ($gallery['images'] ?? [] as $img) {
     foreach ($gallery['images'] ?? [] as $img) {
@@ -376,6 +584,9 @@ function s3_delete_gallery_objects(array $gallery): void
             s3_delete($img['thumb']);
             s3_delete($img['thumb']);
         }
         }
     }
     }
+    if (!empty($gallery['archive']['key'])) {
+        s3_delete($gallery['archive']['key']);
+    }
 }
 }
 
 
 /** Human-readable reason for a PHP upload error code. */
 /** Human-readable reason for a PHP upload error code. */

+ 40 - 3
app/storage.php

@@ -208,13 +208,34 @@ function site_save(array $site): void
 // Galleries — one JSON file per gallery in data/galleries/
 // Galleries — one JSON file per gallery in data/galleries/
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-function gallery_file(string $slug): string
+/**
+ * Path of one of a gallery's data files. The single place a slug becomes a
+ * filesystem path, so the validation below covers every one of them.
+ */
+function gallery_path(string $slug, string $suffix): string
 {
 {
     // Slugs are generated by us, but never trust a request parameter in a path.
     // Slugs are generated by us, but never trust a request parameter in a path.
     if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/', $slug) || strlen($slug) > 120) {
     if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/', $slug) || strlen($slug) > 120) {
         throw new InvalidArgumentException('Invalid gallery slug');
         throw new InvalidArgumentException('Invalid gallery slug');
     }
     }
-    return DATA_DIR . '/galleries/' . $slug . '.json';
+    return DATA_DIR . '/galleries/' . $slug . $suffix;
+}
+
+function gallery_file(string $slug): string
+{
+    return gallery_path($slug, '.json');
+}
+
+/** Progress state of an in-flight archive build (see app/archive.php). */
+function gallery_archive_file(string $slug): string
+{
+    return gallery_path($slug, '.archive.json');
+}
+
+/** The archive build's pending multipart part, waiting to reach 5 MB. */
+function gallery_archive_buffer(string $slug): string
+{
+    return gallery_path($slug, '.archive.buf');
 }
 }
 
 
 function gallery_load(string $slug): ?array
 function gallery_load(string $slug): ?array
@@ -235,11 +256,18 @@ function gallery_save(array $gallery): void
 
 
 function gallery_delete(string $slug): void
 function gallery_delete(string $slug): void
 {
 {
+    // Any half-finished archive build dies with the gallery. This has to abort
+    // the multipart upload it was feeding, not just drop the local state file —
+    // S3 stores and bills for the parts of an incomplete upload indefinitely.
+    archive_abort($slug);
+    archive_unqueue($slug);
+
     $file = gallery_file($slug);
     $file = gallery_file($slug);
     if (is_file($file)) {
     if (is_file($file)) {
         unlink($file);
         unlink($file);
     }
     }
     @unlink($file . '.lock');
     @unlink($file . '.lock');
+    @unlink(gallery_archive_file($slug) . '.lock');
 }
 }
 
 
 /**
 /**
@@ -260,7 +288,12 @@ function gallery_append_image(string $slug, array $image): ?int
         $g['images'][] = $image;
         $g['images'][] = $image;
         return $g;
         return $g;
     });
     });
-    return $missing ? null : count($gallery['images'] ?? []);
+    if ($missing) {
+        return null;
+    }
+    // The gallery's ZIP archive, if it has one, no longer matches its contents.
+    archive_mark_dirty($slug, $gallery);
+    return count($gallery['images'] ?? []);
 }
 }
 
 
 /** All galleries, newest first. */
 /** All galleries, newest first. */
@@ -268,6 +301,10 @@ function galleries_all(): array
 {
 {
     $out = [];
     $out = [];
     foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) {
     foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) {
+        // Skip the archive-build sidecars that live in the same directory.
+        if (str_ends_with($file, '.archive.json')) {
+            continue;
+        }
         $g = json_read($file);
         $g = json_read($file);
         if ($g !== []) {
         if ($g !== []) {
             $out[] = $g;
             $out[] = $g;

+ 226 - 0
app/zip.php

@@ -0,0 +1,226 @@
+<?php
+/**
+ * Minimal ZIP writer in plain PHP — no ZipArchive, no Composer.
+ *
+ * Only what a gallery archive needs: **stored** entries (method 0, no
+ * compression) and ZIP64. Photos are JPEG/RAW and do not compress, so deflating
+ * them would burn CPU for nothing; storing them means an entry's bytes are a
+ * byte-for-byte copy of the S3 object, which is what lets the builder stream
+ * each photo straight through (see app/archive.php).
+ *
+ * The functions here only produce byte strings — they never touch S3, and the
+ * only file handle they see is the one zip_patch_local_header() writes back to.
+ *
+ * Layout produced:
+ *
+ *   [local header][file bytes] … [central directory][ZIP64 EOCD + locator][EOCD]
+ *
+ * Local headers are written **before** the file's bytes are known, so their CRC
+ * and size fields start as placeholders and are patched afterwards
+ * (zip_patch_local_header). That is why every local header carries a ZIP64 extra
+ * field unconditionally: the layout has to be fixed before the size is known,
+ * and a >4 GB entry must not require a different one. Central directory entries
+ * are built at the end, when the real values are known, so those use ZIP64 only
+ * where a field actually overflows.
+ *
+ * No data descriptors are used (the patch makes them unnecessary), which keeps
+ * the output readable by the widest range of tools.
+ */
+
+declare(strict_types=1);
+
+/** Fixed part of a local file header, before filename and extra field. */
+const ZIP_LOCAL_FIXED = 30;
+/** ZIP64 extra field in a local header: id + size + 8-byte sizes. */
+const ZIP_ZIP64_EXTRA = 20;
+/** Value meaning "look in the ZIP64 extra field for the real number". */
+const ZIP_MAX32 = 0xFFFFFFFF;
+
+/** Convert a Unix timestamp to the DOS date/time pair ZIP stores. */
+function zip_dos_time(int $timestamp): array
+{
+    $t = getdate($timestamp);
+    // DOS time cannot represent anything before 1980; clamp rather than wrap.
+    if ($t['year'] < 1980) {
+        return [0, 0x21]; // 1980-01-01 00:00:00
+    }
+    $time = ($t['hours'] << 11) | ($t['minutes'] << 5) | (int)($t['seconds'] / 2);
+    $date = (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
+    return [$time, $date];
+}
+
+/**
+ * Total length of the local header for $name, i.e. how far the file's bytes sit
+ * from the start of the entry. Callers need this to track archive offsets.
+ */
+function zip_local_header_size(string $name): int
+{
+    return ZIP_LOCAL_FIXED + strlen($name) + ZIP_ZIP64_EXTRA;
+}
+
+/**
+ * Local file header with placeholder CRC and sizes, to be patched once the
+ * file's bytes have been written (zip_patch_local_header).
+ *
+ * Flag bit 11 marks the filename as UTF-8 so "Straße.jpg" survives; without it
+ * readers fall back to CP437 and mangle anything non-ASCII.
+ */
+function zip_local_header(string $name, int $mtime): string
+{
+    [$time, $date] = zip_dos_time($mtime);
+
+    return pack('V', 0x04034b50)   // local file header signature
+        . pack('v', 45)            // version needed to extract: 4.5 (ZIP64)
+        . pack('v', 0x0800)        // flags: UTF-8 filename
+        . pack('v', 0)             // method: stored
+        . pack('v', $time)
+        . pack('v', $date)
+        . pack('V', 0)             // CRC-32      — patched later
+        . pack('V', ZIP_MAX32)     // compressed size   → ZIP64 extra
+        . pack('V', ZIP_MAX32)     // uncompressed size → ZIP64 extra
+        . pack('v', strlen($name))
+        . pack('v', ZIP_ZIP64_EXTRA)
+        . $name
+        . pack('v', 0x0001)        // ZIP64 extended information extra field
+        . pack('v', 16)            // ... holding two 8-byte sizes
+        . pack('P', 0)             // uncompressed size — patched later
+        . pack('P', 0);            // compressed size   — patched later
+}
+
+/**
+ * Fill in the CRC and size a local header was written without.
+ *
+ * $entryOffset is the position of the header's signature within $fh. The handle
+ * is left at end-of-file so the caller can carry on appending.
+ *
+ * @param resource $fh
+ */
+function zip_patch_local_header($fh, int $entryOffset, string $name, int $crc, int $size): void
+{
+    // CRC-32 sits 14 bytes into the fixed header; the two ZIP64 sizes sit 4
+    // bytes into the extra field, which follows the filename.
+    fseek($fh, $entryOffset + 14);
+    fwrite($fh, pack('V', $crc));
+
+    fseek($fh, $entryOffset + ZIP_LOCAL_FIXED + strlen($name) + 4);
+    fwrite($fh, pack('P', $size) . pack('P', $size));
+
+    fseek($fh, 0, SEEK_END);
+}
+
+/**
+ * One central directory entry.
+ *
+ * $entry: ['name' => string, 'crc' => int, 'size' => int, 'offset' => int,
+ *          'mtime' => int] — offset being the entry's local header position.
+ *
+ * ZIP64 fields appear only when a value genuinely overflows 32 bits, and the
+ * spec requires them in a fixed order (uncompressed, compressed, offset), each
+ * present only if its fixed-record counterpart was set to 0xFFFFFFFF.
+ */
+function zip_central_entry(array $entry): string
+{
+    [$time, $date] = zip_dos_time($entry['mtime']);
+    $name = $entry['name'];
+    $size = $entry['size'];
+    $offset = $entry['offset'];
+
+    $bigSize = $size >= ZIP_MAX32;
+    $bigOffset = $offset >= ZIP_MAX32;
+
+    $extra = '';
+    if ($bigSize) {
+        $extra .= pack('P', $size) . pack('P', $size);
+    }
+    if ($bigOffset) {
+        $extra .= pack('P', $offset);
+    }
+    if ($extra !== '') {
+        $extra = pack('v', 0x0001) . pack('v', strlen($extra)) . $extra;
+    }
+
+    return pack('V', 0x02014b50)   // central file header signature
+        . pack('v', 45)            // version made by: 4.5, MS-DOS
+        . pack('v', 45)            // version needed to extract
+        . pack('v', 0x0800)        // flags: UTF-8 filename
+        . pack('v', 0)             // method: stored
+        . pack('v', $time)
+        . pack('v', $date)
+        . pack('V', $entry['crc'])
+        . pack('V', $bigSize ? ZIP_MAX32 : $size)
+        . pack('V', $bigSize ? ZIP_MAX32 : $size)
+        . pack('v', strlen($name))
+        . pack('v', strlen($extra))
+        . pack('v', 0)             // file comment length
+        . pack('v', 0)             // disk number start
+        . pack('v', 0)             // internal file attributes
+        . pack('V', 0)             // external file attributes
+        . pack('V', $bigOffset ? ZIP_MAX32 : $offset)
+        . $name
+        . $extra;
+}
+
+/**
+ * The archive trailer: ZIP64 end-of-central-directory record, its locator, and
+ * the classic EOCD.
+ *
+ * The ZIP64 pair is always emitted. A reader that predates ZIP64 skips straight
+ * to the classic EOCD at the end of the file and works as long as nothing
+ * overflows; one that supports ZIP64 finds the locator immediately before it and
+ * gets the wide values. Emitting both is what lets a 300 MB archive and a 40 GB
+ * archive share a single code path.
+ */
+function zip_end_of_central_directory(int $count, int $cdSize, int $cdOffset): string
+{
+    $zip64Eocd = pack('V', 0x06064b50)      // ZIP64 EOCD signature
+        . pack('P', 44)                     // size of the rest of this record
+        . pack('v', 45)                     // version made by
+        . pack('v', 45)                     // version needed
+        . pack('V', 0)                      // this disk
+        . pack('V', 0)                      // disk with start of CD
+        . pack('P', $count)                 // entries on this disk
+        . pack('P', $count)                 // entries total
+        . pack('P', $cdSize)
+        . pack('P', $cdOffset);
+
+    $locator = pack('V', 0x07064b50)        // ZIP64 EOCD locator signature
+        . pack('V', 0)                      // disk with the ZIP64 EOCD
+        . pack('P', $cdOffset + $cdSize)    // its offset: right after the CD
+        . pack('V', 1);                     // total number of disks
+
+    $eocd = pack('V', 0x06054b50)           // EOCD signature
+        . pack('v', 0)                      // this disk
+        . pack('v', 0)                      // disk with start of CD
+        . pack('v', min($count, 0xFFFF))
+        . pack('v', min($count, 0xFFFF))
+        . pack('V', min($cdSize, ZIP_MAX32))
+        . pack('V', min($cdOffset, ZIP_MAX32))
+        . pack('v', 0);                     // archive comment length
+
+    return $zip64Eocd . $locator . $eocd;
+}
+
+/**
+ * Make $name unique within the archive, remembering what has been used.
+ *
+ * Gallery images keep their original filename while their S3 key gets a random
+ * token, so two cameras both producing DSC_0001.jpg is entirely normal — and a
+ * ZIP with duplicate names silently loses files on extraction.
+ */
+function zip_dedupe_name(string $name, array &$seen): string
+{
+    $key = strtolower($name);
+    if (!isset($seen[$key])) {
+        $seen[$key] = 1;
+        return $name;
+    }
+
+    $ext = pathinfo($name, PATHINFO_EXTENSION);
+    $base = $ext !== '' ? substr($name, 0, -(strlen($ext) + 1)) : $name;
+    do {
+        $candidate = $base . ' (' . (++$seen[$key]) . ')' . ($ext !== '' ? '.' . $ext : '');
+    } while (isset($seen[strtolower($candidate)]));
+
+    $seen[strtolower($candidate)] = 1;
+    return $candidate;
+}

+ 144 - 0
assets/archive.js

@@ -0,0 +1,144 @@
+/*
+ * Gallery archive builder — the admin's "Rebuild now" button.
+ *
+ * The archive normally rebuilds itself in the background, but the photographer
+ * often wants it finished before sending the link out. Building a multi-gigabyte
+ * ZIP cannot happen in one request on a 60 s execution limit, so this loops:
+ * POST step, get back {done, total, finished}, repeat until finished.
+ *
+ * Every piece of state lives on the server, so this loop is disposable — closing
+ * the tab pauses the build, "Resume" picks it up at the photo it stopped on, and
+ * the background worker would eventually finish it regardless.
+ */
+(function () {
+    'use strict';
+
+    var card = document.getElementById('archive-card');
+    if (!card) return;
+
+    var api = card.dataset.api;
+    var slug = card.dataset.slug;
+    var csrf = card.dataset.csrf;
+
+    var buildBtn = document.getElementById('archive-build');
+    var cancelBtn = document.getElementById('archive-cancel');
+    var bar = document.getElementById('archive-bar');
+    var fill = bar.querySelector('span');
+    var status = document.getElementById('archive-status');
+
+    var running = false;
+    var stopped = false;
+    var startedAt = 0;
+    var startedFrom = 0;
+
+    function post(action) {
+        var body = new FormData();
+        body.append('slug', slug);
+        body.append('action', action);
+        return fetch(api, {
+            method: 'POST',
+            headers: { 'X-CSRF-Token': csrf },
+            body: body
+        }).then(function (response) {
+            return response.json().catch(function () {
+                return { error: 'Server returned a non-JSON response (HTTP ' + response.status + ')' };
+            });
+        });
+    }
+
+    /* Rough finish time from the rate this run has actually achieved. */
+    function eta(done, total) {
+        var elapsed = (Date.now() - startedAt) / 1000;
+        var processed = done - startedFrom;
+        if (processed < 1 || elapsed < 1) return '';
+        var remaining = Math.round((total - done) * (elapsed / processed));
+        if (remaining < 60) return ' · about a minute left';
+        return ' · about ' + Math.round(remaining / 60) + ' min left';
+    }
+
+    function show(done, total, suffix) {
+        bar.hidden = false;
+        fill.style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
+        status.textContent = done + ' of ' + total + ' photos' + (suffix || '');
+    }
+
+    function fail(message, done, total) {
+        running = false;
+        bar.hidden = total > 0 ? false : true;
+        status.innerHTML = '';
+        status.appendChild(document.createTextNode(message + ' '));
+        var resume = document.createElement('button');
+        resume.className = 'btn-ghost';
+        resume.style.margin = '0';
+        resume.textContent = 'Resume';
+        resume.addEventListener('click', function () { run('step'); });
+        status.appendChild(resume);
+        buildBtn.disabled = false;
+        cancelBtn.hidden = true;
+    }
+
+    function run(action) {
+        running = true;
+        stopped = false;
+        buildBtn.disabled = true;
+        cancelBtn.hidden = false;
+        startedAt = Date.now();
+        startedFrom = -1;
+
+        var retried = false;
+
+        function step(which) {
+            post(which).then(function (result) {
+                if (stopped) return;
+
+                if (result.error && !result.total) {
+                    // A transient S3 hiccup is worth one silent retry; anything
+                    // else needs the photographer to see it.
+                    if (!retried) {
+                        retried = true;
+                        step('step');
+                        return;
+                    }
+                    fail(result.error, 0, 0);
+                    return;
+                }
+                retried = false;
+                if (startedFrom < 0) startedFrom = result.done;
+
+                if (result.finished) {
+                    running = false;
+                    show(result.total, result.total, ' · done');
+                    // Reload so the status line, size and share-side button all
+                    // reflect the archive that now exists.
+                    window.location.reload();
+                    return;
+                }
+
+                if (result.error) {
+                    show(result.done, result.total, '');
+                    fail(result.error, result.done, result.total);
+                    return;
+                }
+
+                show(result.done, result.total, eta(result.done, result.total));
+                step('step');
+            }).catch(function (err) {
+                if (!stopped) fail('Network error: ' + err.message, 0, 0);
+            });
+        }
+
+        step(action);
+    }
+
+    buildBtn.addEventListener('click', function () {
+        if (running) return;
+        // "start" discards any half-finished attempt and opens a fresh upload.
+        run(buildBtn.dataset.resume ? 'step' : 'start');
+    });
+
+    cancelBtn.addEventListener('click', function () {
+        stopped = true;
+        running = false;
+        post('cancel').then(function () { window.location.reload(); });
+    });
+})();

+ 39 - 0
assets/site.css

@@ -433,6 +433,45 @@ button, .btn {
 
 
 .btn-danger { background: var(--danger); color: #fff; }
 .btn-danger { background: var(--danger); color: #fff; }
 
 
+/* A download offered but not currently available: the gallery's archive is
+   being rebuilt. Rendered as a <span> with a title, so hovering explains why. */
+.btn-disabled { opacity: .45; cursor: not-allowed; }
+
+/* Gallery heading row: title and subtitle on the left, download on the right.
+   The subtitle carries the block's bottom margin, so the button sits flush
+   with the title rather than adding space of its own. */
+.page-head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 1.5rem;
+    flex-wrap: wrap;
+}
+.page-action { margin-top: 0; flex-shrink: 0; }
+
+@media (max-width: 600px) {
+    /* Too narrow to sit beside the title; give it the full width instead. */
+    .page-head { gap: 0; }
+    .page-head .page-sub { margin-bottom: 1.2rem; }
+    .page-action { width: 100%; text-align: center; margin-bottom: 2.5rem; }
+}
+
+/* Archive build progress in the backoffice. */
+.archive-bar {
+    height: 4px;
+    background: var(--line);
+    border-radius: 2px;
+    overflow: hidden;
+    margin-bottom: 1rem;
+}
+.archive-bar span {
+    display: block;
+    height: 100%;
+    width: 0;
+    background: var(--fg);
+    transition: width .3s ease;
+}
+
 .flash { padding: .8rem 1.1rem; border-radius: 4px; margin-bottom: 1.5rem; font-size: .9rem; }
 .flash { padding: .8rem 1.1rem; border-radius: 4px; margin-bottom: 1.5rem; font-size: .9rem; }
 .flash-ok    { background: rgba(111,191,143,.12); border: 1px solid rgba(111,191,143,.4); color: var(--ok); }
 .flash-ok    { background: rgba(111,191,143,.12); border: 1px solid rgba(111,191,143,.4); color: var(--ok); }
 .flash-error { background: rgba(224,91,77,.12);  border: 1px solid rgba(224,91,77,.4);  color: var(--danger); }
 .flash-error { background: rgba(224,91,77,.12);  border: 1px solid rgba(224,91,77,.4);  color: var(--danger); }

+ 24 - 0
config/config.sample.php

@@ -53,4 +53,28 @@ return [
         // below the host's per-site process limit. 1 restores serial uploads.
         // below the host's per-site process limit. 1 restores serial uploads.
         'concurrency'   => 3,
         'concurrency'   => 3,
     ],
     ],
+
+    // ---- Download-all archives --------------------------------------------
+    // A gallery with downloads enabled gets one ZIP built into S3 and rebuilt
+    // whenever its images change. The build is split into slices so that no
+    // request ever approaches the host's max_execution_time, which on shared
+    // hosting is typically 60 s and cannot be raised.
+    'archive' => [
+        // Seconds of work per request. Must stay comfortably below
+        // max_execution_time: a slice stops starting new photos at this point,
+        // but the one already running still has to finish.
+        'step_seconds'   => 25,
+        // How long a gallery must go unchanged before its archive is rebuilt.
+        // Guests uploading over an hour should trigger one rebuild, not thirty.
+        'settle_seconds' => 300,
+        // Bytes buffered before they are sent as one multipart part. S3 rejects
+        // parts under 5 MB (the final part excepted), so do not lower this.
+        'part_min_bytes' => 5 * 1024 * 1024,
+        // A build with no progress for this long is assumed dead: its multipart
+        // upload is aborted (S3 bills for the parts) and it restarts.
+        'abandon_hours'  => 24,
+        // Safety net on the self-dispatching background worker chain. A normal
+        // build is a few dozen links long.
+        'max_chain'      => 500,
+    ],
 ];
 ];

+ 70 - 2
docs/ARCHITECTURE.md

@@ -14,15 +14,21 @@ index.php          landing page (hero + intro)            ← document root
 showreel.php       fullscreen portfolio, scroll-snap
 showreel.php       fullscreen portfolio, scroll-snap
 gallery/           client gallery viewer, served as /gallery/?g=<slug>
 gallery/           client gallery viewer, served as /gallery/?g=<slug>
   index.php        password gate, expiry, grid + lightbox
   index.php        password gate, expiry, grid + lightbox
+  download.php     redirects to the presigned URL of the gallery's ZIP
+worker.php         background archive builder (self-dispatching, key-protected)
 admin/             backoffice (session-protected)
 admin/             backoffice (session-protected)
   api.php          JSON API for the uploader (presign / register)
   api.php          JSON API for the uploader (presign / register)
-assets/            site.css, site.js (nav + lightbox), admin.js (uploader)
+  archive-api.php  JSON API for building an archive on demand
+assets/            site.css, site.js (nav + lightbox), admin.js (uploader),
+                   archive.js (archive build progress)
 media/             local images: hero + showreel (full resolution)
 media/             local images: hero + showreel (full resolution)
 app/               library code — blocked by .htaccess
 app/               library code — blocked by .htaccess
   bootstrap.php    config loading, session, helpers
   bootstrap.php    config loading, session, helpers
   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 GET/PUT, signed DELETE)
+  s3.php           AWS Signature v4 (presign, PUT, DELETE, GET, multipart)
+  zip.php          store-only ZIP64 writer
+  archive.php      archive build slices, dirty queue, worker dispatch
   csrf.php         CSRF tokens
   csrf.php         CSRF tokens
   partials.php     shared HTML header/footer for public + admin pages
   partials.php     shared HTML header/footer for public + admin pages
 config/            static config (S3, site) + admin credentials — blocked
 config/            static config (S3, site) + admin credentials — blocked
@@ -154,6 +160,65 @@ rule bounds each PHP process to a single image, so the total gallery size is
 irrelevant — only the **largest single image** must fit within the host's
 irrelevant — only the **largest single image** must fit within the host's
 `upload_max_filesize` / `post_max_size` (see SETUP.md).
 `upload_max_filesize` / `post_max_size` (see SETUP.md).
 
 
+## Gallery archives ("Download all")
+
+Enable downloads on a gallery and visitors get one ZIP of every photo. It is
+built **once, into S3**, and the visitor is redirected to a presigned URL for it
+(`gallery/download.php`) — so a 3 GB download runs browser ↔ S3, resumable via
+Range requests, and never occupies the webhost at all.
+
+**Why not stream the ZIP through PHP.** `max_execution_time` on shared hosting is
+typically 60 s and cannot be raised, while a gallery can hold 400+ originals of
+8 MB. A streaming `download.php` would have to stay alive for the entire
+transfer. `ZipArchive` is out for the same reason plus disk quota, and a Composer
+package is out by project policy. Building ahead of time is what makes the
+60 s cap irrelevant.
+
+**Slices.** `archive_run_slice()` copies as many photos as fit in
+`archive.step_seconds` (default 25) and returns; state is committed after every
+photo, so a build is "run slices until finished". The budget is checked *before*
+starting a photo and never during one, with headroom for one as slow as the
+slowest seen so far — but a slice always does at least one photo, so a gallery of
+very large files still creeps forward instead of stalling.
+
+Each photo streams S3 → buffer file → S3 in a single pass that also computes its
+CRC-32. The ZIP needs a local header immediately before each file's bytes and
+multipart parts are atomic, so `UploadPartCopy` cannot be used and the bytes must
+travel through the webhost. The header is written with placeholder values and
+patched once the real size and CRC are known. The buffer accumulates until it
+passes the 5 MB multipart minimum, then becomes one part; the final part carries
+the central directory and is exempt from the minimum.
+
+**Interruptions.** State goes through `json_write()` (tmp + rename), so it is
+never half-written. The rest is ordering:
+
+| Interrupted | Recovery |
+| --- | --- |
+| between photos | resume at `next_index`; nothing to undo |
+| mid photo | every slice starts by truncating the buffer back to the last committed length, so a partial tail needs no error handling to clean up |
+| mid part upload | ETag is committed only after S3 accepts, buffer truncated only after that — a crash re-uploads the same part number, which S3 allows |
+| mid completion | a retried complete returns `NoSuchUpload` once it has already succeeded; a HEAD confirms the object and the build counts as done |
+| abandoned | the queue entry survives; a build with no progress for `archive.abandon_hours` is aborted (freeing the multipart parts S3 bills for) and restarted |
+
+**Staying current.** Every stored or deleted image marks its gallery dirty
+(`archive_mark_dirty()`, hooked into `gallery_append_image()`). While a gallery is
+dirty its download button renders disabled with a hover explanation, and
+`gallery/download.php` refuses too — a client must never receive a ZIP that
+silently omits the newest photos. Rebuilds are batched by
+`archive.settle_seconds` (default 5 min) so thirty guests uploading over an hour
+cause one rebuild, not thirty.
+
+**Getting work done without cron.** `archive_kick()` runs at the end of every
+public page render. It flushes the page to the visitor first, then fires a
+request at `worker.php` and hangs up; the worker runs a slice and dispatches its
+own successor, so one upload starts a chain that finishes unattended. The chain
+lives exactly as long as the queue is non-empty and is bounded by
+`archive.max_chain`; a site-wide `flock` keeps it to one worker. Where the host
+cannot make an HTTP request to itself, the same page-render hook runs a slice
+inline after `fastcgi_finish_request()` instead, and progress needs one page view
+per slice. A real cron job hitting `worker.php?key=…` works too and is better
+than either (see SETUP.md).
+
 ## Security model
 ## Security model
 
 
 - **Admin auth**: credentials in `config/credentials.php`
 - **Admin auth**: credentials in `config/credentials.php`
@@ -187,3 +252,6 @@ irrelevant — only the **largest single image** must fit within the host's
   if two admin tabs edit the same gallery's settings simultaneously.
   if two admin tabs edit the same gallery's settings simultaneously.
 - Presigned URLs mean gallery pages must be re-rendered after `s3.url_ttl`;
 - Presigned URLs mean gallery pages must be re-rendered after `s3.url_ttl`;
   a visitor who keeps a tab open >1 h reloads to see images again.
   a visitor who keeps a tab open >1 h reloads to see images again.
+- A gallery archive doubles that gallery's S3 storage while it exists, and
+  rebuilding is all-or-nothing: one new photo re-copies the whole gallery
+  through the webhost. The settle delay keeps that to once per upload burst.

+ 30 - 0
docs/SETUP.md

@@ -48,6 +48,36 @@ a tight per-site process limit, lower it:
 If uploads start failing with 503s under load, that limit is the first thing to
 If uploads start failing with 503s under load, that limit is the first thing to
 check.
 check.
 
 
+## 2b. Download-all archives
+
+Enabling downloads on a gallery builds one ZIP of it into S3, so visitors get a
+single file without the webhost ever serving the bytes. Two things to know:
+
+- **Storage.** The archive roughly doubles that gallery's S3 storage for as long
+  as it exists. Disabling downloads deletes it again.
+- **Build traffic.** Building copies every photo S3 → webhost → S3 (about twice
+  the gallery's size in webhost traffic), once per rebuild. It is split into
+  slices of `archive.step_seconds` so no request approaches
+  `max_execution_time` — the 60 s cap common on shared hosting is fine and
+  needs no change.
+
+Rebuilds normally happen on their own: a page view dispatches `worker.php`,
+which runs a slice and then dispatches its successor until the archive is
+finished. If your host **blocks outbound HTTP to itself**, that chain cannot
+start, and archives instead advance one slice per page view. A real cron job
+removes the guesswork entirely — every 5 minutes is plenty:
+
+```
+*/5 * * * * curl -s "https://www.example.com/worker.php?key=YOUR_WORKER_KEY" >/dev/null
+```
+
+The key is generated on first use and stored in `data/worker-key.json`; read it
+from there. Without a valid key the script returns a bare 404.
+
+An interrupted build always resumes where it stopped, and one abandoned for
+`archive.abandon_hours` (default 24) is aborted and restarted — which also
+releases the incomplete multipart upload S3 would otherwise keep billing for.
+
 ## 3. Configuration
 ## 3. Configuration
 
 
 ```bash
 ```bash

+ 45 - 0
gallery/download.php

@@ -0,0 +1,45 @@
+<?php
+/**
+ * Gallery archive download: /gallery/download.php?g=<slug>
+ *
+ * Hands the visitor a short-lived presigned URL for the gallery's prebuilt ZIP
+ * and gets out of the way. The transfer runs browser ↔ S3, so a 3 GB archive is
+ * unaffected by the host's 60 s execution limit and stays resumable — this
+ * script only ever sends a redirect.
+ *
+ * An out-of-date archive is refused rather than served, mirroring the disabled
+ * button on the gallery page: a client must not be handed a ZIP that silently
+ * omits the photos uploaded since it was built.
+ *
+ * Every failure looks the same as an unknown gallery, so the endpoint reveals
+ * nothing the visitor does not already know from the link.
+ */
+define('SITE_BASE', '../');
+
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+session_boot();
+
+$slug = (string)($_GET['g'] ?? '');
+$gallery = $slug !== '' ? gallery_load($slug) : null;
+
+$allowed = $gallery !== null
+    && !gallery_is_expired($gallery)
+    && !empty($gallery['downloads_enabled'])
+    && !empty($gallery['archive']['key'])
+    && !archive_is_stale($gallery)
+    // The same per-gallery session unlock the viewer sets.
+    && (empty($gallery['password_hash']) || !empty($_SESSION['gallery_unlocked'][$slug]));
+
+if (!$allowed) {
+    http_response_code(404);
+    public_header('Download not available');
+    echo '<div class="gate"><div class="gate-card"><h1>Download not available</h1>'
+       . '<p class="page-sub">This download does not exist or is not ready yet.</p></div></div>';
+    public_footer();
+    exit;
+}
+
+// Short TTL on purpose: the browser only needs the URL long enough to start the
+// transfer. A GET already in flight is not cut off when the signature expires.
+redirect(s3_presign_get((string)$gallery['archive']['key'], 900));

+ 27 - 6
gallery/index.php

@@ -62,13 +62,34 @@ if ($needsPassword && !$unlocked) {
 public_header(e($gallery['title']));
 public_header(e($gallery['title']));
 ?>
 ?>
 <main class="page">
 <main class="page">
-    <h1 class="page-title"><?= e($gallery['title']) ?></h1>
-    <p class="page-sub">
-        <?= count($gallery['images'] ?? []) ?> photos
-        <?php if (!empty($gallery['expires_at'])): ?>
-            · available until <?= e($gallery['expires_at']) ?>
+    <div class="page-head">
+        <div>
+            <h1 class="page-title"><?= e($gallery['title']) ?></h1>
+            <p class="page-sub">
+                <?= count($gallery['images'] ?? []) ?> photos
+                <?php if (!empty($gallery['expires_at'])): ?>
+                    · available until <?= e($gallery['expires_at']) ?>
+                <?php endif; ?>
+            </p>
+        </div>
+        <?php if (!empty($gallery['downloads_enabled'])): ?>
+            <?php
+            // Outside .grid on purpose: assets/site.js binds the lightbox to
+            // every <a> inside the grid and would swallow this link's click.
+            $archive = $gallery['archive'] ?? null;
+            ?>
+            <?php if ($archive !== null && !archive_is_stale($gallery)): ?>
+                <a class="btn btn-ghost page-action" href="download.php?g=<?= e(rawurlencode($slug)) ?>">
+                    Download all · <?= e(human_bytes((int)$archive['size'])) ?>
+                </a>
+            <?php else: ?>
+                <span class="btn btn-ghost btn-disabled page-action"
+                      title="The zip archive is outdated and is being recreated. This can take up to an hour — please check back later.">
+                    Download all
+                </span>
+            <?php endif; ?>
         <?php endif; ?>
         <?php endif; ?>
-    </p>
+    </div>
     <div class="grid">
     <div class="grid">
         <?php foreach ($gallery['images'] ?? [] as $img): ?>
         <?php foreach ($gallery['images'] ?? [] as $img): ?>
             <a href="<?= e(s3_presign_get($img['key'])) ?>">
             <a href="<?= e(s3_presign_get($img['key'])) ?>">

+ 1 - 1
router.php

@@ -13,7 +13,7 @@ $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
 
 
 $blocked = preg_match('#^/(app|config|data|docs)/#', $path)   // internals
 $blocked = preg_match('#^/(app|config|data|docs)/#', $path)   // internals
     || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
     || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
-    || preg_match('#\.(json|md|lock)$#', $path)               // data/docs
+    || preg_match('#\.(json|md|lock|buf)$#', $path)           // data/docs
     || str_ends_with($path, '.sample.php');                   // config templates
     || str_ends_with($path, '.sample.php');                   // config templates
 
 
 if ($blocked) {
 if ($blocked) {

+ 93 - 0
worker.php

@@ -0,0 +1,93 @@
+<?php
+/**
+ * Background archive worker.
+ *
+ * Shared hosting has no dependable cron, so gallery archives are rebuilt by a
+ * chain of short requests instead: a page render calls archive_kick(), which
+ * fires one request at this script and hangs up; this script does a slice of
+ * work and then dispatches its own successor. One guest upload therefore starts
+ * a chain that runs to completion with no further visitors.
+ *
+ * Each request does exactly one of two things, never both, so it cannot
+ * approach max_execution_time:
+ *
+ *   - a gallery is due  → run one slice (archive.step_seconds), then dispatch
+ *   - only unsettled    → sleep out the rest of the settle window (capped at
+ *     entries remain      30 s), then dispatch
+ *
+ * The site-wide lock is held across the whole body, including the sleep. That
+ * is what keeps the chain single: a worker that cannot take the lock exits
+ * without dispatching, because the worker that holds it will dispatch the next
+ * one itself. The chain also stops as soon as the queue is empty, so it can
+ * never become a perpetual heartbeat, and archive.max_chain bounds it even if
+ * something goes wrong.
+ *
+ * Authenticated by the key in data/worker-key.json: the caller is this server
+ * making an HTTP request to itself, so there is no admin session to check. A
+ * wrong or missing key is indistinguishable from the script not existing.
+ *
+ * If the host offers real cron, calling this URL every few minutes works just as
+ * well and needs no code change (see docs/SETUP.md).
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+if (!hash_equals(archive_worker_key(), (string)($_GET['key'] ?? ''))) {
+    http_response_code(404);
+    exit;
+}
+
+// The dispatcher hung up after a fraction of a second. Without this, PHP would
+// kill this process the moment it noticed the disconnect.
+ignore_user_abort(true);
+@set_time_limit(0);   // honoured on some hosts; the design never relies on it.
+
+// Nothing is ever read from the response — the caller is not listening.
+http_response_code(204);
+
+$lock = archive_lock();
+if ($lock === null) {
+    exit;   // another worker owns the chain and will dispatch its successor
+}
+
+$chain = json_update(archive_queue_file(), function (array $queue): array {
+    $queue['chain'] = (int)($queue['chain'] ?? 0) + 1;
+    return $queue;
+});
+
+if ((int)($chain['chain'] ?? 0) > (int)config('archive.max_chain', 500)) {
+    exit;   // runaway guard; the next page view starts a fresh chain
+}
+
+$wait = archive_queue_wait();
+
+if ($wait === null) {
+    // Queue empty: the chain ends here, and its counter resets with it.
+    json_update(archive_queue_file(), function (array $queue): array {
+        $queue['chain'] = 0;
+        return $queue;
+    });
+    exit;
+}
+
+if ($wait > 0) {
+    // Nothing has settled yet. Waiting here rather than exiting is what lets a
+    // gallery nobody is looking at still rebuild on its own.
+    //
+    // Kept short: this holds both a PHP process and the worker lock, and an
+    // admin clicking "Rebuild now" has to wait it out. A settle window is
+    // bridged by a chain of these short waits instead of one long one.
+    sleep(min($wait, 15));
+} else {
+    $slug = archive_next_due();
+    if ($slug !== null) {
+        archive_run_slice($slug);
+    }
+}
+
+// Release before handing off, so the successor can start immediately.
+flock($lock, LOCK_UN);
+fclose($lock);
+
+if (archive_queue_wait() !== null) {
+    archive_dispatch(2000);
+}