CORS-Preflight beantworten, damit das Projekte-Modal speichern kann
Schreibende Aufrufe aus dem Frontend (POST/PATCH/DELETE auf /repo-configs und /agents) schicken X-Admin-Key und Content-Type: application/json. Beides macht aus dem fetch() einen preflighted request: Der Browser fragt zuerst per OPTIONS nach. Fuer OPTIONS war aber keine Route registriert, der Router warf daher MethodNotAllowedException (405), und ausserdem fehlte Access-Control-Allow-Headers fuer den X-Admin-Key. Der Browser sendete den eigentlichen POST damit nie und das Modal meldete nur "Netzwerkfehler beim Abrufen der Ressource" - Lesen funktionierte, weil GET ohne Custom-Header ein simple request ohne Preflight ist. Application::handle() beantwortet OPTIONS jetzt vor dem Routing mit 204, und Response::send() liefert Allow-Methods, Allow-Headers und Max-Age mit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ebf7dba537
commit
2f5321ac8e
3 changed files with 41 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue