api.php 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. <?php
  2. /**
  3. * Admin upload endpoint used by the browser-side uploader (assets/admin.js).
  4. *
  5. * One multipart POST per image (X-CSRF-Token header, fields below); the webhost
  6. * streams the file straight to S3 and appends it to the gallery's JSON file.
  7. * Keeping it to a single file per request means each PHP process only ever
  8. * handles one image, so a multi-gigabyte gallery upload never trips
  9. * post_max_size / max_execution_time — only the largest single image does.
  10. *
  11. * Fields:
  12. * slug gallery slug
  13. * original the full-resolution file (required, stored unmodified)
  14. * thumb browser-generated JPEG thumbnail (optional; absent for RAW/video)
  15. *
  16. * The browser never receives an S3 URL or any credential for writing.
  17. */
  18. require dirname(__DIR__) . '/app/bootstrap.php';
  19. if (!auth_check()) {
  20. json_response(['error' => 'Not authenticated'], 401);
  21. }
  22. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  23. json_response(['error' => 'POST only'], 405);
  24. }
  25. csrf_verify();
  26. // One image per request; a single file may still be large, so lift the time cap.
  27. @set_time_limit(0);
  28. /** Human-readable reason for a PHP upload error code. */
  29. function upload_error_message(int $code): string
  30. {
  31. return match ($code) {
  32. UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file exceeds the server upload size limit',
  33. UPLOAD_ERR_PARTIAL => 'upload was interrupted',
  34. UPLOAD_ERR_NO_FILE => 'no file received',
  35. UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'server cannot store the upload',
  36. default => 'upload error ' . $code,
  37. };
  38. }
  39. // When a request body exceeds post_max_size, PHP discards $_POST and $_FILES
  40. // entirely — surface that as a clear 413 instead of a misleading "no file".
  41. if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) {
  42. json_response(['error' => 'Upload exceeds the server post_max_size limit'], 413);
  43. }
  44. $gallery = gallery_load((string)($_POST['slug'] ?? ''));
  45. if ($gallery === null) {
  46. json_response(['error' => 'Unknown gallery'], 404);
  47. }
  48. $slug = $gallery['slug'];
  49. $original = $_FILES['original'] ?? null;
  50. if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  51. json_response(['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))], 400);
  52. }
  53. if (!is_uploaded_file((string)$original['tmp_name'])) {
  54. json_response(['error' => 'Invalid upload'], 400);
  55. }
  56. $name = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
  57. $token = random_token(6);
  58. $base = s3_gallery_prefix($slug);
  59. $key = "$base/originals/$token-$name";
  60. // Stream the original to S3 byte-for-byte from the PHP upload temp file.
  61. $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
  62. [$status, $resp] = s3_put_file($key, (string)$original['tmp_name'], $type);
  63. if ($status < 200 || $status >= 300) {
  64. json_response(['error' => "S3 rejected the original (HTTP $status)"], 502);
  65. }
  66. // Optional browser-generated thumbnail. A thumb failure is non-fatal: the
  67. // original stays, and the grid falls back to the original key.
  68. $thumbKey = null;
  69. $thumb = $_FILES['thumb'] ?? null;
  70. if (is_array($thumb)
  71. && ($thumb['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
  72. && is_uploaded_file((string)$thumb['tmp_name'])
  73. ) {
  74. $candidate = "$base/thumbs/$token-$name.jpg";
  75. [$tstatus] = s3_put_file($candidate, (string)$thumb['tmp_name'], 'image/jpeg');
  76. if ($tstatus >= 200 && $tstatus < 300) {
  77. $thumbKey = $candidate;
  78. } else {
  79. s3_delete($candidate);
  80. }
  81. }
  82. // Append under a fresh load to reduce lost updates between concurrent uploads.
  83. $gallery = gallery_load($slug);
  84. $gallery['images'][] = [
  85. 'key' => $key,
  86. 'thumb' => $thumbKey,
  87. 'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
  88. 'size' => (int)($original['size'] ?? 0),
  89. ];
  90. gallery_save($gallery);
  91. json_response(['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]);