diff --git a/api/src/Application.php b/api/src/Application.php index 811f23f..4be28bd 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -119,6 +119,14 @@ final class Application public function handle(Request $request): Response { + // CORS-Preflight generisch beantworten, bevor der Router laeuft: OPTIONS ist fuer + // keinen Pfad registriert, der Router wuerde also MethodNotAllowedException (405) + // werfen. Der Browser sendet den eigentlichen POST/PATCH/DELETE dann gar nicht mehr + // und meldet nur einen Netzwerkfehler - genau das brach das Projekte-Modal am PO. + if ($request->method === 'OPTIONS') { + return Response::preflight(); + } + try { $match = $this->router->match($request); } catch (MethodNotAllowedException) { diff --git a/api/src/Http/Response.php b/api/src/Http/Response.php index 37aea42..6614101 100644 --- a/api/src/Http/Response.php +++ b/api/src/Http/Response.php @@ -30,6 +30,15 @@ final class Response return new self($status, ['error' => $message]); } + /** + * Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft + * stehen in den Access-Control-Headern aus send(). + */ + public static function preflight(): self + { + return new self(204, null); + } + /** * @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und * schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife). @@ -58,6 +67,13 @@ final class Response // liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen // gueltigen API-Key, ein offener Origin ist daher unkritisch. header('Access-Control-Allow-Origin: *'); + // Schreibende Aufrufe schicken X-Admin-Key/X-Api-Key und Content-Type: application/json. + // Beides macht aus dem fetch() einen "preflighted request": Der Browser fragt vorher per + // OPTIONS nach und bricht mit einem reinen Netzwerkfehler ab, wenn die erlaubten Methoden + // und Header fehlen - ohne dass der eigentliche POST je gesendet wird. + header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, X-Admin-Key, X-Api-Key'); + header('Access-Control-Max-Age: 86400'); if ($this->streamWriter !== null) { http_response_code($this->status); @@ -80,6 +96,11 @@ final class Response } http_response_code($this->status); + + if ($this->status === 204) { + return; + } + header('Content-Type: application/json; charset=utf-8'); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index c85d97c..5b8c046 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -132,6 +132,18 @@ final class ApplicationTest extends TestCase self::assertIsArray($response->body()['repos']); } + /** + * Der X-Admin-Key-Header macht aus dem fetch() im Projekte-Modal einen preflighted + * request. Wird OPTIONS nicht mit 2xx beantwortet, sendet der Browser den POST nie und + * das Modal zeigt nur "Netzwerkfehler beim Abrufen der Ressource". + */ + public function testCorsPreflightOnAdminEndpointSucceeds(): void + { + $response = $this->app->handle(Request::create('OPTIONS', '/api/v1/repo-configs')); + + self::assertSame(204, $response->status); + } + public function testCreateRepoConfigRequiresAdminKey(): void { $response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [