upload-api.php 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. <?php
  2. /**
  3. * Public guest upload endpoint used by the browser-side uploader (assets/admin.js)
  4. * on upload.php. Same one-multipart-POST-per-image contract as admin/api.php, but
  5. * authenticated by the per-gallery upload key instead of an admin session.
  6. *
  7. * Fields: slug, key, original (required), thumb, batch, seq (optional; batch and
  8. * seq carry the file's place in the visitor's selection, so parallel uploads are
  9. * stored in the order they were picked). Access requires the
  10. * gallery to have guest uploads enabled, the key to match, the gallery to be
  11. * unexpired, and — if the gallery has a password — the visitor to have unlocked
  12. * it in this session (via upload.php). Any failure returns a uniform 403.
  13. *
  14. * Uploads are image-only here, so a public link cannot store arbitrary files.
  15. *
  16. * Guest uploads always land in the gallery's guest topic, created on demand.
  17. * The topic is decided here, never taken from the request, so a guest cannot
  18. * drop photos into the photographer's own sections.
  19. */
  20. require __DIR__ . '/app/bootstrap.php';
  21. session_boot();
  22. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  23. json_response(['error' => 'POST only'], 405);
  24. }
  25. csrf_verify();
  26. // Take the session-held facts now, then release the session file before the slow
  27. // S3 leg. PHP locks it exclusively for the whole request, so without this every
  28. // parallel upload from the browser would queue behind the previous one and the
  29. // uploader would be serial again no matter how many requests it starts.
  30. $unlockedGalleries = (array)($_SESSION['gallery_unlocked'] ?? []);
  31. session_write_close();
  32. // One image per request; a single file may still be large, so lift the time cap.
  33. @set_time_limit(0);
  34. // When a request body exceeds post_max_size, PHP discards $_POST and $_FILES
  35. // entirely — surface that as a clear 413 instead of a misleading "no file".
  36. if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) {
  37. json_response(['error' => 'Upload exceeds the server post_max_size limit'], 413);
  38. }
  39. $gallery = gallery_load((string)($_POST['slug'] ?? ''));
  40. // Uniform 403 for every access failure (no key, wrong key, expired, locked) so
  41. // the endpoint reveals nothing a guest shouldn't already know from the link.
  42. $authorized = $gallery !== null
  43. && !empty($gallery['upload_key'])
  44. && hash_equals((string)$gallery['upload_key'], (string)($_POST['key'] ?? ''))
  45. && !gallery_is_expired($gallery)
  46. && (empty($gallery['password_hash']) || !empty($unlockedGalleries[$gallery['slug']]));
  47. if (!$authorized) {
  48. json_response(['error' => 'Not authorized'], 403);
  49. }
  50. // Image-only: a public link must not be usable to store arbitrary file types.
  51. [$status, $payload] = gallery_store_s3_upload(
  52. $gallery,
  53. $_FILES['original'] ?? null,
  54. $_FILES['thumb'] ?? null,
  55. true,
  56. $_POST,
  57. GALLERY_GUEST_TOPIC
  58. );
  59. json_response($payload, $status);