Kaynağa Gözat

added download all button to gallery, added zip archive generation

Medowar 4 saat önce
ebeveyn
işleme
e6ba67d306
18 değiştirilmiş dosya ile 1903 ekleme ve 92 silme
  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
 # 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
 </FilesMatch>
 
 # Belt-and-suspenders for older Apache that lacks the FilesMatch above.
 <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
         Deny from all
     </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));
     }
 
+    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') {
         $key = (string)($_POST['key'] ?? '');
         foreach ($gallery['images'] ?? [] as $i => $img) {
@@ -61,6 +76,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
                 }
                 array_splice($gallery['images'], $i, 1);
                 gallery_save($gallery);
+                // The archive no longer matches the gallery's contents.
+                archive_mark_dirty($slug, $gallery);
                 flash_set('Image deleted.');
                 break;
             }
@@ -154,6 +171,64 @@ flash_render();
     <?php endif; ?>
 </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">
     <?= csrf_field() ?>
     <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/auth.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/partials.php';
 
@@ -59,6 +61,22 @@ function e(?string $s): string
     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). */
 function session_boot(): void
 {

+ 4 - 0
app/partials.php

@@ -47,6 +47,10 @@ function public_footer(): void
 </body>
 </html>
 <?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

+ 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;
 }
 
+/**
+ * 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
  * 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.
  *
+ * $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
  */
-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);
+    $canonicalQuery = s3_canonical_query($query);
     $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();
     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_INFILE         => $fh,
         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_TIME  => 120,
         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);
     $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);
-    $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();
     curl_setopt_array($ch, [
-        CURLOPT_URL            => s3_base_url() . $canonicalUri,
+        CURLOPT_URL            => s3_url($key, $query),
         CURLOPT_CUSTOMREQUEST  => strtoupper($method),
         CURLOPT_RETURNTRANSFER => true,
         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);
-    return [$status, (string)$body];
+    return [$status, (string)$response, $responseHeaders];
 }
 
 /** 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;
 }
 
-/** 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
 {
     foreach ($gallery['images'] ?? [] as $img) {
@@ -376,6 +584,9 @@ function s3_delete_gallery_objects(array $gallery): void
             s3_delete($img['thumb']);
         }
     }
+    if (!empty($gallery['archive']['key'])) {
+        s3_delete($gallery['archive']['key']);
+    }
 }
 
 /** 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/
 // ---------------------------------------------------------------------------
 
-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.
     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');
     }
-    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
@@ -235,11 +256,18 @@ function gallery_save(array $gallery): 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);
     if (is_file($file)) {
         unlink($file);
     }
     @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;
         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. */
@@ -268,6 +301,10 @@ function galleries_all(): array
 {
     $out = [];
     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);
         if ($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; }
 
+/* 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-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); }

+ 24 - 0
config/config.sample.php

@@ -53,4 +53,28 @@ return [
         // below the host's per-site process limit. 1 restores serial uploads.
         '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
 gallery/           client gallery viewer, served as /gallery/?g=<slug>
   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)
   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)
 app/               library code — blocked by .htaccess
   bootstrap.php    config loading, session, helpers
   storage.php      JSON flat-file store, slugs, local media handling
   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
   partials.php     shared HTML header/footer for public + admin pages
 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
 `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
 
 - **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.
 - 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 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
 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
 
 ```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']));
 ?>
 <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; ?>
-    </p>
+    </div>
     <div class="grid">
         <?php foreach ($gallery['images'] ?? [] as $img): ?>
             <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
     || 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
 
 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);
+}