'Not authenticated'], 401); } 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); /** 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)($_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'])]);