'POST only'], 405); } csrf_verify(); // Take the session-held facts now, then release the session file before the slow // S3 leg. PHP locks it exclusively for the whole request, so without this every // parallel upload from the browser would queue behind the previous one and the // uploader would be serial again no matter how many requests it starts. $unlockedGalleries = (array)($_SESSION['gallery_unlocked'] ?? []); session_write_close(); // 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($unlockedGalleries[$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);