Ver Fonte

Add guest upload links for galleries

Admins can now hand out a shareable link that lets guests upload into a
gallery without an admin account. A gallery gains an optional upload_key
(presence = enabled); the link is /upload.php?g=<slug>&k=<key>, gated by:
the key (hash_equals), the gallery's existing password (reusing the
viewer's session unlock), and its expiry. A wrong/missing key returns the
same neutral "not available" page as a missing gallery, so links can't be
enumerated.

The public endpoint upload-api.php is image-only so a link can't be used
to store arbitrary files. Both admin/api.php and upload-api.php now share
one ingest helper, gallery_store_s3_upload(), which streams the original
(+ optional thumb) to S3 and appends to the gallery JSON.

Admins enable uploads at creation (checkbox) or from the gallery editor,
which shows the link plus Regenerate (rotates the key, invalidating the
old link) and Disable controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Medowar há 1 mês atrás
pai
commit
380ac09369
7 ficheiros alterados com 306 adições e 62 exclusões
  1. 7 61
      admin/api.php
  2. 12 1
      admin/galleries.php
  3. 62 0
      admin/gallery-edit.php
  4. 87 0
      app/s3.php
  5. 3 0
      assets/admin.js
  6. 53 0
      upload-api.php
  7. 82 0
      upload.php

+ 7 - 61
admin/api.php

@@ -28,18 +28,6 @@ csrf_verify();
 // 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) {
@@ -50,53 +38,11 @@ $gallery = gallery_load((string)($_POST['slug'] ?? ''));
 if ($gallery === null) {
     json_response(['error' => 'Unknown gallery'], 404);
 }
-$slug = $gallery['slug'];
-
-$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";
-
-// 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);
-}
-
-// 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'])]);
+// Trusted admin path: any file type is allowed (imagesOnly stays false).
+[$status, $payload] = gallery_store_s3_upload(
+    $gallery,
+    $_FILES['original'] ?? null,
+    $_FILES['thumb'] ?? null
+);
+json_response($payload, $status);

+ 12 - 1
admin/galleries.php

@@ -25,6 +25,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
             'expires_at'    => trim((string)($_POST['expires_at'] ?? '')) ?: null,
             'images'        => [],
         ];
+        // A guest upload link is just a per-gallery secret in the URL; presence
+        // of upload_key = guest uploads enabled (revocable from the edit page).
+        if (!empty($_POST['allow_uploads'])) {
+            $gallery['upload_key'] = random_token(24);
+        }
         gallery_save($gallery);
         flash_set('Gallery created. Now add images.');
         redirect('gallery-edit.php?g=' . rawurlencode($slug));
@@ -58,6 +63,9 @@ flash_render();
     <input type="text" id="p" name="password" autocomplete="off">
     <label for="ex">Expiry date <span style="text-transform:none;letter-spacing:0">(optional — gallery is hidden after this day)</span></label>
     <input type="date" id="ex" name="expires_at">
+    <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
+        <input type="checkbox" name="allow_uploads" value="1"> Allow guest uploads via a shared link
+    </label></p>
     <button type="submit">Create gallery</button>
 </form>
 
@@ -71,7 +79,10 @@ flash_render();
     <tr>
         <td><?= e($g['title']) ?></td>
         <td><?= count($g['images'] ?? []) ?></td>
-        <td><?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?></td>
+        <td>
+            <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
+            <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>
+        </td>
         <td>
             <?= e($g['expires_at'] ?? '—') ?>
             <?= $expired ? ' <span class="tag tag-expired">expired</span>' : '' ?>

+ 62 - 0
admin/gallery-edit.php

@@ -30,6 +30,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         redirect('gallery-edit.php?g=' . rawurlencode($slug));
     }
 
+    if ($action === 'uploads') {
+        $mode = (string)($_POST['mode'] ?? '');
+        if ($mode === 'enable' && empty($gallery['upload_key'])) {
+            $gallery['upload_key'] = random_token(24);
+            gallery_save($gallery);
+            flash_set('Guest uploads enabled.');
+        } elseif ($mode === 'regenerate') {
+            $gallery['upload_key'] = random_token(24);
+            gallery_save($gallery);
+            flash_set('New upload link generated; the old link no longer works.');
+        } elseif ($mode === 'disable') {
+            $gallery['upload_key'] = null;
+            gallery_save($gallery);
+            flash_set('Guest uploads disabled.');
+        }
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+
     if ($action === 'delete-image') {
         $key = (string)($_POST['key'] ?? '');
         foreach ($gallery['images'] ?? [] as $i => $img) {
@@ -56,6 +74,11 @@ $host    = $_SERVER['HTTP_HOST'] ?? 'localhost';
 $basePath = str_replace('\\', '/', dirname(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/gallery-edit.php')));
 $basePath = rtrim($basePath, '/');
 $shareUrl = $scheme . '://' . $host . $basePath . '/gallery.php?g=' . rawurlencode($slug);
+// Guest upload link (only when a key is set). Same host/path derivation as above.
+$uploadUrl = !empty($gallery['upload_key'])
+    ? $scheme . '://' . $host . $basePath . '/upload.php?g=' . rawurlencode($slug)
+        . '&k=' . rawurlencode($gallery['upload_key'])
+    : '';
 
 admin_header($gallery['title'], 'galleries');
 flash_render();
@@ -80,6 +103,45 @@ flash_render();
     <div class="upload-list" id="upload-list"></div>
 </div>
 
+<div class="card">
+    <h2 style="margin-top:0">Guest uploads</h2>
+    <?php if ($uploadUrl !== ''): ?>
+        <p class="help" style="margin-bottom:1rem">
+            Share this link so guests can upload into this gallery without an admin
+            account. It is key-protected and honors the gallery's password and
+            expiry, if set.
+        </p>
+        <p style="margin-bottom:1rem">
+            <a href="<?= e($uploadUrl) ?>" target="_blank" rel="noopener"><?= e($uploadUrl) ?></a>
+        </p>
+        <form method="post" style="display:inline"
+              onsubmit="return confirm('Generate a new link? The current link will stop working.')">
+            <?= csrf_field() ?>
+            <input type="hidden" name="action" value="uploads">
+            <input type="hidden" name="mode" value="regenerate">
+            <button style="margin:0">Regenerate link</button>
+        </form>
+        <form method="post" style="display:inline"
+              onsubmit="return confirm('Disable guest uploads? The link will stop working.')">
+            <?= csrf_field() ?>
+            <input type="hidden" name="action" value="uploads">
+            <input type="hidden" name="mode" value="disable">
+            <button class="btn-danger" style="margin:0">Disable</button>
+        </form>
+    <?php else: ?>
+        <p class="help" style="margin-bottom:1rem">
+            Guest uploads are disabled. Enable them to get a shareable link that
+            lets people upload into this gallery without an admin account.
+        </p>
+        <form method="post">
+            <?= csrf_field() ?>
+            <input type="hidden" name="action" value="uploads">
+            <input type="hidden" name="mode" value="enable">
+            <button style="margin:0">Enable guest uploads</button>
+        </form>
+    <?php endif; ?>
+</div>
+
 <form method="post" class="card">
     <?= csrf_field() ?>
     <input type="hidden" name="action" value="settings">

+ 87 - 0
app/s3.php

@@ -314,3 +314,90 @@ function s3_delete_gallery_objects(array $gallery): void
         }
     }
 }
+
+/** 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,
+    };
+}
+
+/**
+ * Ingest one uploaded image into a gallery: stream the original (and optional
+ * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
+ *
+ * Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
+ * The gallery is re-loaded under a fresh read before appending to reduce lost
+ * updates between concurrent uploads. Object keys are generated server-side
+ * under the gallery's own prefix — never taken from the client.
+ *
+ * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
+ * original must have a recognised image extension and decode via getimagesize(),
+ * so a public link cannot be used to store arbitrary file types.
+ *
+ * Returns [int $httpStatus, array $payload] for the caller to hand to
+ * json_response(); a thumbnail failure is non-fatal (the grid falls back to the
+ * original key).
+ */
+function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
+{
+    if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
+    }
+    if (!is_uploaded_file((string)$original['tmp_name'])) {
+        return [400, ['error' => 'Invalid upload']];
+    }
+
+    if ($imagesOnly) {
+        $ext = strtolower(pathinfo((string)($original['name'] ?? ''), PATHINFO_EXTENSION));
+        if (!in_array($ext, MEDIA_EXTENSIONS, true) || getimagesize((string)$original['tmp_name']) === false) {
+            return [400, ['error' => 'Only image files are allowed']];
+        }
+    }
+
+    $slug  = $gallery['slug'];
+    $name  = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
+    $token = random_token(6);
+    $base  = s3_gallery_prefix($slug);
+    $key   = "$base/originals/$token-$name";
+
+    // Stream the original to S3 byte-for-byte from the PHP upload temp file.
+    $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
+    [$status] = s3_put_file($key, (string)$original['tmp_name'], $type);
+    if ($status < 200 || $status >= 300) {
+        return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
+    }
+
+    // Optional browser-generated thumbnail. A thumb failure is non-fatal: the
+    // original stays, and the grid falls back to the original key.
+    $thumbKey = 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);
+
+    return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]];
+}

+ 3 - 0
assets/admin.js

@@ -23,6 +23,8 @@
     var api = zone.dataset.api;
     var slug = zone.dataset.slug;
     var csrf = zone.dataset.csrf;
+    // Guest upload links pass a per-gallery key; the admin edit page sets none.
+    var uploadKey = zone.dataset.key || '';
     var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
     var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
 
@@ -145,6 +147,7 @@
             setState(row, 'uploading');
             var form = new FormData();
             form.append('slug', slug);
+            if (uploadKey) form.append('key', uploadKey);
             form.append('original', file, file.name);
             if (thumbBlob) form.append('thumb', thumbBlob, 'thumb.jpg');
             return sendForm(form, function (f) {

+ 53 - 0
upload-api.php

@@ -0,0 +1,53 @@
+<?php
+/**
+ * Public guest upload endpoint used by the browser-side uploader (assets/admin.js)
+ * on upload.php. Same one-multipart-POST-per-image contract as admin/api.php, but
+ * authenticated by the per-gallery upload key instead of an admin session.
+ *
+ * Fields: slug, key, original (required), thumb (optional). Access requires the
+ * gallery to have guest uploads enabled, the key to match, the gallery to be
+ * unexpired, and — if the gallery has a password — the visitor to have unlocked
+ * it in this session (via upload.php). Any failure returns a uniform 403.
+ *
+ * Uploads are image-only here, so a public link cannot store arbitrary files.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+session_boot();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    json_response(['error' => 'POST only'], 405);
+}
+csrf_verify();
+
+// One image per request; a single file may still be large, so lift the time cap.
+@set_time_limit(0);
+
+// 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)($_POST['slug'] ?? ''));
+
+// Uniform 403 for every access failure (no key, wrong key, expired, locked) so
+// the endpoint reveals nothing a guest shouldn't already know from the link.
+$authorized = $gallery !== null
+    && !empty($gallery['upload_key'])
+    && hash_equals((string)$gallery['upload_key'], (string)($_POST['key'] ?? ''))
+    && !gallery_is_expired($gallery)
+    && (empty($gallery['password_hash']) || !empty($_SESSION['gallery_unlocked'][$gallery['slug']]));
+
+if (!$authorized) {
+    json_response(['error' => 'Not authorized'], 403);
+}
+
+// Image-only: a public link must not be usable to store arbitrary file types.
+[$status, $payload] = gallery_store_s3_upload(
+    $gallery,
+    $_FILES['original'] ?? null,
+    $_FILES['thumb'] ?? null,
+    true
+);
+json_response($payload, $status);

+ 82 - 0
upload.php

@@ -0,0 +1,82 @@
+<?php
+/**
+ * Public guest uploader: /upload.php?g=<slug>&k=<upload_key>
+ *
+ * Lets someone without an admin account upload into a gallery, gated by:
+ *   1. a per-gallery secret key in the URL (k), compared with hash_equals,
+ *   2. the gallery's password (if set), reusing the viewer's session unlock,
+ *   3. the gallery's expiry.
+ * A wrong/missing key is indistinguishable from a missing gallery — the same
+ * neutral "not available" page as gallery.php, so links can't be enumerated.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+session_boot();
+
+$slug = (string)($_GET['g'] ?? '');
+$gallery = $slug !== '' ? gallery_load($slug) : null;
+
+$keyOk = $gallery !== null
+    && !empty($gallery['upload_key'])
+    && hash_equals((string)$gallery['upload_key'], (string)($_GET['k'] ?? ''));
+
+if ($gallery === null || gallery_is_expired($gallery) || !$keyOk) {
+    http_response_code(404);
+    public_header('Upload not available');
+    echo '<div class="gate"><div class="gate-card"><h1>Upload not available</h1>'
+       . '<p class="page-sub">This upload link does not exist or is no longer active.</p></div></div>';
+    public_footer();
+    exit;
+}
+
+$needsPassword = !empty($gallery['password_hash']);
+$unlocked = !$needsPassword || !empty($_SESSION['gallery_unlocked'][$slug]);
+
+if ($needsPassword && !$unlocked && $_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    if (password_verify((string)($_POST['password'] ?? ''), $gallery['password_hash'])) {
+        $_SESSION['gallery_unlocked'][$slug] = true;
+        redirect('upload.php?g=' . rawurlencode($slug) . '&k=' . rawurlencode((string)$gallery['upload_key']));
+    }
+    $error = 'Wrong password.';
+}
+
+if ($needsPassword && !$unlocked) {
+    public_header(e($gallery['title']));
+    ?>
+    <div class="gate"><div class="gate-card">
+        <h1><?= e($gallery['title']) ?></h1>
+        <?php if (!empty($error)): ?><div class="flash flash-error"><?= e($error) ?></div><?php endif; ?>
+        <form method="post">
+            <?= csrf_field() ?>
+            <label for="pw">Password</label>
+            <input type="password" id="pw" name="password" autofocus autocomplete="off">
+            <button type="submit">Continue</button>
+        </form>
+    </div></div>
+    <?php
+    public_footer();
+    exit;
+}
+
+public_header(e($gallery['title']));
+?>
+<main class="page">
+    <h1 class="page-title"><?= e($gallery['title']) ?></h1>
+    <p class="page-sub">Upload your photos to this gallery.</p>
+
+    <div class="dropzone" id="dropzone"
+         data-api="upload-api.php"
+         data-slug="<?= e($slug) ?>"
+         data-csrf="<?= e(csrf_token()) ?>"
+         data-key="<?= e((string)$gallery['upload_key']) ?>"
+         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>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>
+</main>
+<script src="assets/admin.js"></script>
+<?php public_footer(); ?>