Jelajahi Sumber

Proxy uploads through the webhost instead of direct browser-to-S3

Uploads now POST one image per request to admin/api.php, which streams
the original (and optional browser thumbnail) to S3 with a signed PUT
(UNSIGNED-PAYLOAD, so the body is never buffered in memory). This drops
the presigned-PUT + register JSON dance and the bucket CORS requirement:
uploads are same-origin and no S3 write credential ever reaches the
browser. One file per request bounds each PHP process to a single image,
so only the largest original must fit post_max_size, not the whole
gallery.

Also make S3 addressing configurable (s3.path_style) and the object key
prefix configurable (s3.prefix, default "galleries"), and derive the
gallery share link from the current request host rather than config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Medowar 1 bulan lalu
induk
melakukan
c6a8cb34d1
7 mengubah file dengan 318 tambahan dan 170 penghapusan
  1. 78 45
      admin/api.php
  2. 11 4
      admin/gallery-edit.php
  3. 132 13
      app/s3.php
  4. 29 51
      assets/admin.js
  5. 9 0
      config/config.sample.php
  6. 37 27
      docs/ARCHITECTURE.md
  7. 22 30
      docs/SETUP.md

+ 78 - 45
admin/api.php

@@ -1,15 +1,19 @@
 <?php
 /**
- * Admin JSON API used by the browser-side uploader (assets/admin.js).
+ * Admin upload endpoint used by the browser-side uploader (assets/admin.js).
  *
- * POST JSON body: { "action": "...", ... } with X-CSRF-Token header.
+ * One multipart POST per image (X-CSRF-Token header, fields below); the webhost
+ * streams the file straight to S3 and appends it to the gallery's JSON file.
+ * Keeping it to a single file per request means each PHP process only ever
+ * handles one image, so a multi-gigabyte gallery upload never trips
+ * post_max_size / max_execution_time — only the largest single image does.
  *
- * Actions:
- *   presign  { slug, name }
- *     → presigned PUT URLs for the full-resolution original and its
- *       browser-generated thumbnail.
- *   register { slug, key, thumb, name, size }
- *     → append an uploaded image to the gallery's JSON file.
+ * Fields:
+ *   slug      gallery slug
+ *   original  the full-resolution file (required, stored unmodified)
+ *   thumb     browser-generated JPEG thumbnail (optional; absent for RAW/video)
+ *
+ * The browser never receives an S3 URL or any credential for writing.
  */
 require dirname(__DIR__) . '/app/bootstrap.php';
 
@@ -21,49 +25,78 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
 }
 csrf_verify();
 
-$body = json_decode((string)file_get_contents('php://input'), true) ?: [];
-$action = (string)($body['action'] ?? '');
+// One image per request; a single file may still be large, so lift the time cap.
+@set_time_limit(0);
+
+/** Human-readable reason for a PHP upload error code. */
+function upload_error_message(int $code): string
+{
+    return match ($code) {
+        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file exceeds the server upload size limit',
+        UPLOAD_ERR_PARTIAL                        => 'upload was interrupted',
+        UPLOAD_ERR_NO_FILE                        => 'no file received',
+        UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'server cannot store the upload',
+        default                                   => 'upload error ' . $code,
+    };
+}
+
+// When a request body exceeds post_max_size, PHP discards $_POST and $_FILES
+// entirely — surface that as a clear 413 instead of a misleading "no file".
+if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) {
+    json_response(['error' => 'Upload exceeds the server post_max_size limit'], 413);
+}
 
-$gallery = gallery_load((string)($body['slug'] ?? ''));
+$gallery = gallery_load((string)($_POST['slug'] ?? ''));
 if ($gallery === null) {
     json_response(['error' => 'Unknown gallery'], 404);
 }
 $slug = $gallery['slug'];
 
-switch ($action) {
-    case 'presign':
-        $name = substr(safe_filename((string)($body['name'] ?? '')), 0, 120);
-        // Random prefix avoids overwrites when two files share a name.
-        $token = random_token(6);
-        $key   = "galleries/$slug/originals/$token-$name";
-        $thumb = "galleries/$slug/thumbs/$token-$name.jpg";
-        json_response([
-            'key'          => $key,
-            'thumb'        => $thumb,
-            'put_original' => s3_presign_put($key, 3600),
-            'put_thumb'    => s3_presign_put($thumb, 3600),
-        ]);
+$original = $_FILES['original'] ?? null;
+if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+    json_response(['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))], 400);
+}
+if (!is_uploaded_file((string)$original['tmp_name'])) {
+    json_response(['error' => 'Invalid upload'], 400);
+}
+
+$name  = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
+$token = random_token(6);
+$base  = s3_gallery_prefix($slug);
+$key   = "$base/originals/$token-$name";
 
-    case 'register':
-        $key   = (string)($body['key'] ?? '');
-        $thumb = (string)($body['thumb'] ?? '');
-        if (!str_starts_with($key, "galleries/$slug/")) {
-            json_response(['error' => 'Key does not belong to this gallery'], 400);
-        }
-        if ($thumb !== '' && !str_starts_with($thumb, "galleries/$slug/")) {
-            json_response(['error' => 'Thumb key does not belong to this gallery'], 400);
-        }
-        // Re-load under current state to reduce lost updates between requests.
-        $gallery = gallery_load($slug);
-        $gallery['images'][] = [
-            'key'   => $key,
-            'thumb' => $thumb !== '' ? $thumb : null,
-            'name'  => substr((string)($body['name'] ?? basename($key)), 0, 200),
-            'size'  => (int)($body['size'] ?? 0),
-        ];
-        gallery_save($gallery);
-        json_response(['ok' => true, 'count' => count($gallery['images'])]);
+// Stream the original to S3 byte-for-byte from the PHP upload temp file.
+$type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
+[$status, $resp] = s3_put_file($key, (string)$original['tmp_name'], $type);
+if ($status < 200 || $status >= 300) {
+    json_response(['error' => "S3 rejected the original (HTTP $status)"], 502);
+}
 
-    default:
-        json_response(['error' => 'Unknown action'], 400);
+// Optional browser-generated thumbnail. A thumb failure is non-fatal: the
+// original stays, and the grid falls back to the original key.
+$thumbKey = null;
+$thumb = $_FILES['thumb'] ?? null;
+if (is_array($thumb)
+    && ($thumb['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
+    && is_uploaded_file((string)$thumb['tmp_name'])
+) {
+    $candidate = "$base/thumbs/$token-$name.jpg";
+    [$tstatus] = s3_put_file($candidate, (string)$thumb['tmp_name'], 'image/jpeg');
+    if ($tstatus >= 200 && $tstatus < 300) {
+        $thumbKey = $candidate;
+    } else {
+        s3_delete($candidate);
+    }
 }
+
+// Append under a fresh load to reduce lost updates between concurrent uploads.
+$gallery = gallery_load($slug);
+$gallery['images'][] = [
+    'key'   => $key,
+    'thumb' => $thumbKey,
+    'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
+    'size'  => (int)($original['size'] ?? 0),
+];
+gallery_save($gallery);
+
+json_response(['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]);

+ 11 - 4
admin/gallery-edit.php

@@ -1,7 +1,7 @@
 <?php
 /**
- * Per-gallery editor: settings, share link, direct-to-S3 bulk uploader,
- * and image removal.
+ * Per-gallery editor: settings, share link, proxied bulk uploader
+ * (browser → webhost → S3), and image removal.
  */
 require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
@@ -48,7 +48,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     }
 }
 
-$shareUrl = rtrim(config('site.base_url', ''), '/') . '/gallery.php?g=' . rawurlencode($slug);
+// Build the share link from the page the admin is currently on, not from config,
+// so it matches whatever host/path this app is actually served under.
+$scheme  = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+$host    = $_SERVER['HTTP_HOST'] ?? 'localhost';
+// gallery.php lives one directory up from admin/ (cf. the "../gallery.php" link below).
+$basePath = str_replace('\\', '/', dirname(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/gallery-edit.php')));
+$basePath = rtrim($basePath, '/');
+$shareUrl = $scheme . '://' . $host . $basePath . '/gallery.php?g=' . rawurlencode($slug);
 
 admin_header($gallery['title'], 'galleries');
 flash_render();
@@ -67,7 +74,7 @@ flash_render();
          data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
          data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>">
         Drop images here or click to select.<br>
-        <small>Files go directly from your browser to S3, in full resolution, unmodified.</small>
+        <small>Uploaded through the site to S3, in full resolution, unmodified. One file at a time.</small>
     </div>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>

+ 132 - 13
app/s3.php

@@ -4,10 +4,14 @@
  * Implements AWS Signature v4 in plain PHP — no SDK, no Composer.
  *
  * - Presigned GET  → visitors load gallery images directly from S3
- * - Presigned PUT  → the admin browser uploads directly to S3
+ * - Signed PUT     → the webhost streams uploaded originals/thumbs to S3
  * - Signed DELETE  → server-side cleanup when images/galleries are removed
  *
- * Uses path-style URLs: https://<endpoint>/<bucket>/<key>
+ * Addressing style is configurable via s3.path_style:
+ *   path-style (default)    → https://<endpoint-host>/<bucket>/<key>
+ *   virtual-hosted-style    → https://<bucket>.<endpoint-host>/<key>
+ * Hetzner Object Storage serves path-style reliably; virtual-hosted-style
+ * requires the bucket to resolve as a TLS subdomain of the endpoint.
  */
 
 declare(strict_types=1);
@@ -18,9 +22,64 @@ function s3_encode_key(string $key): string
     return implode('/', array_map('rawurlencode', explode('/', $key)));
 }
 
+/** Bare host of the configured endpoint, e.g. "fsn1.your-objectstorage.com". */
+function s3_endpoint_host(): string
+{
+    return (string)parse_url(config('s3.endpoint'), PHP_URL_HOST);
+}
+
+/** ":port" suffix when the endpoint pins a non-default port, else "". */
+function s3_endpoint_port_suffix(): string
+{
+    $port = parse_url(config('s3.endpoint'), PHP_URL_PORT);
+    return $port ? ':' . $port : '';
+}
+
+/** Whether to address the bucket in the path (true) or as a subdomain (false). */
+function s3_use_path_style(): bool
+{
+    return (bool)config('s3.path_style', true);
+}
+
+/**
+ * Request host. Path-style keeps the bare endpoint host; virtual-hosted-style
+ * prepends the bucket as a DNS label (never percent-encoded).
+ */
 function s3_host(): string
 {
-    return parse_url(config('s3.endpoint'), PHP_URL_HOST);
+    $host = s3_endpoint_host();
+    $host = s3_use_path_style() ? $host : config('s3.bucket') . '.' . $host;
+    // The signed Host header and the request host must match, port included.
+    return $host . s3_endpoint_port_suffix();
+}
+
+/** Base URL for object requests: scheme + host, no trailing slash. */
+function s3_base_url(): string
+{
+    $scheme = parse_url(config('s3.endpoint'), PHP_URL_SCHEME) ?: 'https';
+    return $scheme . '://' . s3_host();
+}
+
+/**
+ * Canonical (and actual) request path for a key. Path-style prefixes the
+ * bucket as the first, percent-encoded path segment; virtual-hosted-style
+ * does not, because the bucket lives in the host instead.
+ */
+function s3_canonical_uri(string $key): string
+{
+    $path = '/' . s3_encode_key($key);
+    return s3_use_path_style() ? '/' . rawurlencode(config('s3.bucket')) . $path : $path;
+}
+
+/**
+ * Key prefix under which one gallery's objects live: "<prefix>/<slug>".
+ * The prefix is configurable via s3.prefix (default "galleries"); an empty
+ * prefix puts galleries at the bucket root.
+ */
+function s3_gallery_prefix(string $slug): string
+{
+    $prefix = trim((string)config('s3.prefix', 'galleries'), '/');
+    return $prefix !== '' ? "$prefix/$slug" : $slug;
 }
 
 /** HMAC-SHA256 chain producing the SigV4 signing key. */
@@ -91,14 +150,14 @@ function s3_presign_query(
 }
 
 /**
- * Build a presigned URL for GET or PUT on an object key.
- * Only the Host header is signed, so the browser is free to set its own
- * Content-Type on PUT.
+ * Build a presigned URL for an object key. Only the Host header is signed.
+ * Used for GET so visitors' browsers can load private images directly; uploads
+ * go through the webhost (s3_put_file), never a presigned PUT.
  */
 function s3_presign(string $method, string $key, ?int $ttl = null): string
 {
     $ttl ??= (int)config('s3.url_ttl', 3600);
-    $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
+    $canonicalUri = s3_canonical_uri($key);
     $query = s3_presign_query(
         $method,
         s3_host(),
@@ -109,7 +168,7 @@ function s3_presign(string $method, string $key, ?int $ttl = null): string
         $ttl,
         gmdate('Ymd\THis\Z')
     );
-    return config('s3.endpoint') . $canonicalUri . '?' . $query;
+    return s3_base_url() . $canonicalUri . '?' . $query;
 }
 
 function s3_presign_get(string $key, ?int $ttl = null): string
@@ -117,9 +176,70 @@ function s3_presign_get(string $key, ?int $ttl = null): string
     return s3_presign('GET', $key, $ttl);
 }
 
-function s3_presign_put(string $key, int $ttl = 900): string
+/**
+ * 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
+ * streams it straight from the file handle, letting the webhost proxy originals
+ * far larger than memory_limit. Content-Type is sent but not signed.
+ * Returns [httpStatus, responseBody].
+ */
+function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream'): array
 {
-    return s3_presign('PUT', $key, $ttl);
+    $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 = '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;
+
+    $fh = fopen($filePath, 'rb');
+    if ($fh === false) {
+        return [0, 'Cannot open upload for reading'];
+    }
+    $ch = curl_init(s3_base_url() . $canonicalUri);
+    curl_setopt_array($ch, [
+        CURLOPT_UPLOAD         => true,   // sets method to PUT and streams CURLOPT_INFILE
+        CURLOPT_INFILE         => $fh,
+        CURLOPT_INFILESIZE     => filesize($filePath),
+        CURLOPT_RETURNTRANSFER => true,
+        CURLOPT_CONNECTTIMEOUT => 30,
+        CURLOPT_TIMEOUT        => 0,       // no cap: originals can be large
+        CURLOPT_HTTPHEADER     => [
+            'Authorization: ' . $authorization,
+            'x-amz-content-sha256: ' . $payloadHash,
+            'x-amz-date: ' . $amzDate,
+            'Content-Type: ' . $contentType,
+            'Expect:',                     // skip 100-continue round-trip
+        ],
+    ]);
+    $body = curl_exec($ch);
+    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+    fclose($fh);
+    return [$status, (string)$body];
 }
 
 /**
@@ -132,7 +252,7 @@ function s3_request(string $method, string $key): array
     $amzDate = gmdate('Ymd\THis\Z');
     $date = substr($amzDate, 0, 8);
     $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
-    $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
+    $canonicalUri = s3_canonical_uri($key);
     $payloadHash = hash('sha256', '');
 
     $canonicalRequest = implode("\n", [
@@ -159,7 +279,7 @@ function s3_request(string $method, string $key): array
         . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
         . ', Signature=' . $signature;
 
-    $ch = curl_init(config('s3.endpoint') . $canonicalUri);
+    $ch = curl_init(s3_base_url() . $canonicalUri);
     curl_setopt_array($ch, [
         CURLOPT_CUSTOMREQUEST  => strtoupper($method),
         CURLOPT_RETURNTRANSFER => true,
@@ -172,7 +292,6 @@ function s3_request(string $method, string $key): array
     ]);
     $body = curl_exec($ch);
     $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
-    curl_close($ch);
     return [$status, (string)$body];
 }
 

+ 29 - 51
assets/admin.js

@@ -1,13 +1,14 @@
 /*
  * Gallery bulk uploader.
  *
- * Per file:
- *   1. ask admin/api.php for presigned PUT URLs (original + thumbnail)
- *   2. PUT the original to S3 — byte-for-byte, full resolution, unmodified
- *   3. draw a small JPEG thumbnail on a canvas (browser-side) and PUT it too
- *   4. register the image in the gallery's flat file
+ * Per file, one request:
+ *   1. draw a small JPEG thumbnail on a canvas (browser-side)
+ *   2. POST the original + thumbnail to admin/api.php as multipart/form-data
+ *   3. the webhost streams both to S3 and registers the image
  *
- * The webhost never receives the image data; only tiny JSON requests.
+ * One file per request keeps each PHP process small, so the size of the whole
+ * gallery upload never matters — only the largest single image. The browser
+ * never sees an S3 URL or credential for writing.
  */
 (function () {
     'use strict';
@@ -89,33 +90,24 @@
         if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
     }
 
-    function apiCall(payload) {
-        return fetch(api, {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
-            body: JSON.stringify(payload)
-        }).then(function (res) {
-            if (!res.ok) throw new Error('API error ' + res.status);
-            return res.json();
-        });
-    }
-
-    /* PUT with upload progress (fetch has no upload progress → XHR). */
-    function putToS3(url, data, contentType, onProgress) {
+    /* POST multipart to the webhost with upload progress (fetch has none → XHR). */
+    function sendForm(form, onProgress) {
         return new Promise(function (resolve, reject) {
             var xhr = new XMLHttpRequest();
-            xhr.open('PUT', url);
-            if (contentType) xhr.setRequestHeader('Content-Type', contentType);
+            xhr.open('POST', api);
+            xhr.setRequestHeader('X-CSRF-Token', csrf);
+            // Let the browser set Content-Type (with the multipart boundary).
             xhr.upload.addEventListener('progress', function (e) {
                 if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
             });
             xhr.addEventListener('load', function () {
-                (xhr.status >= 200 && xhr.status < 300)
-                    ? resolve()
-                    : reject(new Error('S3 upload failed (' + xhr.status + ')'));
+                var data = {};
+                try { data = JSON.parse(xhr.responseText); } catch (err) {}
+                if (xhr.status >= 200 && xhr.status < 300 && data.ok) resolve(data);
+                else reject(new Error((data && data.error) || ('Upload failed (' + xhr.status + ')')));
             });
-            xhr.addEventListener('error', function () { reject(new Error('S3 upload network error')); });
-            xhr.send(data);
+            xhr.addEventListener('error', function () { reject(new Error('Network error during upload')); });
+            xhr.send(form);
         });
     }
 
@@ -147,31 +139,17 @@
 
     function uploadOne(file, row) {
         var bar = row.querySelector('.bar i');
-        setState(row, 'preparing');
-
-        return apiCall({ action: 'presign', slug: slug, name: file.name })
-            .then(function (p) {
-                setState(row, 'uploading');
-                return putToS3(p.put_original, file, file.type || 'application/octet-stream', function (f) {
-                    bar.style.width = Math.round(f * 100) + '%';
-                }).then(function () {
-                    setState(row, 'thumbnail');
-                    return makeThumb(file);
-                }).then(function (thumbBlob) {
-                    if (!thumbBlob) return { p: p, thumb: '' };
-                    return putToS3(p.put_thumb, thumbBlob, 'image/jpeg')
-                        .then(function () { return { p: p, thumb: p.thumb }; });
-                }).then(function (r) {
-                    setState(row, 'saving');
-                    return apiCall({
-                        action: 'register',
-                        slug: slug,
-                        key: r.p.key,
-                        thumb: r.thumb,
-                        name: file.name,
-                        size: file.size
-                    });
-                });
+        setState(row, 'thumbnail');
+
+        return makeThumb(file).then(function (thumbBlob) {
+            setState(row, 'uploading');
+            var form = new FormData();
+            form.append('slug', slug);
+            form.append('original', file, file.name);
+            if (thumbBlob) form.append('thumb', thumbBlob, 'thumb.jpg');
+            return sendForm(form, function (f) {
+                bar.style.width = Math.round(f * 100) + '%';
             });
+        });
     }
 })();

+ 9 - 0
config/config.sample.php

@@ -23,6 +23,15 @@ return [
         'bucket'     => 'my-photo-galleries',
         'access_key' => 'CHANGE_ME',
         'secret_key' => 'CHANGE_ME',
+        // Bucket addressing. true → https://<endpoint-host>/<bucket>/<key>
+        // (path-style, recommended for Hetzner). false → virtual-hosted-style
+        // https://<bucket>.<endpoint-host>/<key>, which needs the bucket to
+        // resolve as a TLS subdomain of the endpoint.
+        'path_style' => true,
+        // Key prefix under which gallery objects are stored:
+        // <prefix>/<slug>/originals/... and .../thumbs/... Set to '' to store
+        // galleries at the bucket root.
+        'prefix'     => 'galleries',
         // Lifetime of presigned GET URLs (seconds). Gallery pages embed these;
         // after expiry the browser must reload the page for fresh URLs.
         'url_ttl'    => 3600,

+ 37 - 27
docs/ARCHITECTURE.md

@@ -42,8 +42,8 @@ router.php         local dev only: applies the .htaccess rules under php -S
     "password_hash": "$2y$...",        // or null
     "expires_at": "2026-12-31",         // or null
     "images": [
-      { "key":   "galleries/<slug>/originals/a1b2c3-DSC_0001.jpg",
-        "thumb": "galleries/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
+      { "key":   "<prefix>/<slug>/originals/a1b2c3-DSC_0001.jpg",
+        "thumb": "<prefix>/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
         "name":  "DSC_0001.jpg", "size": 18349201 }
     ]
   }
@@ -68,14 +68,17 @@ re-encode, no EXIF stripping.
 ## Presigned URLs (app/s3.php)
 
 AWS Signature v4 implemented directly (~100 lines, `hash_hmac` only), verified
-against the official AWS example vectors. Three uses:
+against the official AWS example vectors. Addressing style follows
+`s3.path_style` (default **path-style**, `https://<endpoint-host>/<bucket>/<key>`,
+which Hetzner serves reliably; set `false` for virtual-hosted-style
+`https://<bucket>.<endpoint-host>/<key>`). Three uses:
 
 1. **Presigned GET** — `gallery.php` embeds signed image URLs
-   (`s3.url_ttl`, default 1 h). The browser fetches from S3 directly; the
-   webhost serves only HTML.
-2. **Presigned PUT** — `admin/api.php` hands the uploader short-lived upload
-   URLs. Only the `Host` header is signed, so the browser may send its own
-   `Content-Type`.
+   (`s3.url_ttl`, default 1 h). The browser fetches from S3 directly, so gallery
+   image traffic never touches the webhost.
+2. **Signed PUT** — `s3_put_file()` streams uploaded originals and thumbnails
+   from the webhost to S3 (header auth, `UNSIGNED-PAYLOAD` so the body is never
+   buffered in memory). The browser never gets an S3 write URL.
 3. **Signed DELETE** — server-side via curl when images or galleries are
    deleted.
 
@@ -83,27 +86,32 @@ Because the bucket is private, access control is entirely on the PHP side:
 no unlock → no signed URL → no image. Once a gallery expires or is deleted,
 outstanding URLs die within the TTL.
 
-## Upload flow (admin browser → S3)
+## Upload flow (admin browser → webhost → S3)
 
 ```
-admin.js                    api.php                    Hetzner S3
-   │  action=presign  ───────▶ │
-   │ ◀─── key, thumb, 2 PUT URLs
-   │  PUT original (unmodified, full res) ───────────────▶
-   │  canvas → JPEG thumb; PUT thumb ────────────────────▶
-   │  action=register ──────▶ │  appends to gallery JSON
+admin.js                         api.php                    Hetzner S3
+   │  canvas → JPEG thumb
+   │  POST multipart (original + thumb, one file) ─▶ │
+   │                                                 │  PUT original ─────▶
+   │                                                 │  PUT thumb ────────▶
+   │                                                 │  append to gallery JSON
+   │ ◀──────────────────────── { ok, key, thumb, count }
 ```
 
-Files are uploaded sequentially with progress; failures get a per-file retry.
-The thumbnail is drawn client-side (`createImageBitmap` +
-`imageOrientation: 'from-image'` for EXIF rotation). Undecodable files (RAW,
-video) upload without a thumbnail; the grid then falls back to the original
-key. Random 6-char key prefixes prevent same-filename collisions. Requires a
-CORS rule on the bucket (see SETUP.md).
-
-This route exists because shared hosting typically limits `post_max_size` and
-request time — a 2 GB wedding shoot can't pass through the webhost, but it
-can go straight to S3.
+Files are uploaded **one request per file**, sequentially, with progress;
+failures get a per-file retry. The thumbnail is drawn client-side
+(`createImageBitmap` + `imageOrientation: 'from-image'` for EXIF rotation) and
+sent alongside the original; undecodable files (RAW, video) upload without a
+thumbnail and the grid falls back to the original key. A random 6-char token per
+file prevents same-filename collisions. Object keys are laid out as
+`<prefix>/<slug>/{originals,thumbs}/…`, where `<prefix>` comes from `s3.prefix`
+(default `galleries`, `''` = bucket root).
+
+Proxying uploads through the webhost keeps them same-origin (no bucket CORS) and
+means no S3 write credential ever reaches the browser. The one-file-per-request
+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).
 
 ## Security model
 
@@ -124,8 +132,10 @@ can go straight to S3.
   serves images only and disables PHP execution. `router.php` reproduces these
   rules for the PHP built-in server during local development.
 - **Input hygiene**: slugs validated by regex before touching the filesystem;
-  upload filenames sanitized; `register` keys must lie under the gallery's own
-  S3 prefix; all output HTML-escaped via `e()`.
+  upload filenames sanitized; S3 object keys are generated server-side under the
+  gallery's own prefix (never taken from the client); only real
+  `is_uploaded_file()` temp files are streamed to S3; all output HTML-escaped
+  via `e()`.
 
 ## Known trade-offs
 

+ 22 - 30
docs/SETUP.md

@@ -14,35 +14,27 @@
    access only through short-lived presigned URLs.
 2. Create S3 credentials (Security → S3 credentials) and note the
    *access key* and *secret key*.
-3. Configure **CORS** on the bucket so the admin browser may upload directly
-   to S3 and so gallery images load on your domain. Hetzner supports the
-   standard S3 CORS API; using `s3cmd`, `aws s3api`, or any S3 GUI client
-   (e.g. Cyberduck), apply:
-
-   ```json
-   {
-     "CORSRules": [
-       {
-         "AllowedOrigins": ["https://www.your-domain.com"],
-         "AllowedMethods": ["GET", "PUT"],
-         "AllowedHeaders": ["*"],
-         "MaxAgeSeconds": 3600
-       }
-     ]
-   }
-   ```
-
-   With `aws` CLI:
-
-   ```bash
-   aws s3api put-bucket-cors \
-     --endpoint-url https://fsn1.your-objectstorage.com \
-     --bucket my-photo-galleries \
-     --cors-configuration file://cors.json
-   ```
-
-   Replace the origin with your real domain (and add `http://localhost:8080`
-   temporarily if you want to test uploads locally).
+
+No bucket **CORS** rule is needed: uploads are proxied through the webhost
+(same-origin) and gallery images load via `<img>` presigned GET URLs, which
+browsers don't subject to CORS. Keep the bucket **private**.
+
+## 2a. PHP upload limits
+
+Uploads stream through PHP one image per request, so the *total* gallery size is
+irrelevant — but each single image must fit the host's limits. Ensure
+`upload_max_filesize` and `post_max_size` are at least as large as your biggest
+original (e.g. 200M for RAW files); on shared hosting set these in `.user.ini`
+or `php.ini`:
+
+```ini
+upload_max_filesize = 200M
+post_max_size       = 200M
+```
+
+`max_execution_time` is lifted per upload request in code, but if your host caps
+it at the web-server level (e.g. Apache/FPM request timeout), raise that too for
+large files.
 
 ## 3. Configuration
 
@@ -109,7 +101,7 @@ works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
    that navigation hides when scrolling down.
 4. Galleries: create a test gallery **with password and an expiry date of
    today**, upload a handful of images. Then:
-   - the upload list shows *done* for each file (S3 + CORS working),
+   - the upload list shows *done* for each file (webhost → S3 working),
    - the gallery page asks for the password and then shows images
      (presigned GETs working),
    - images load from `your-objectstorage.com`, not from your domain,