Merge pull request 'CORS-Preflight beantworten, damit das Projekte-Modal speichern kann' (#31) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/31
This commit is contained in:
fds2610 2026-08-03 18:57:50 +02:00
commit 40c48ed46a
3 changed files with 41 additions and 0 deletions

View file

@ -119,6 +119,14 @@ final class Application
public function handle(Request $request): Response 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 { try {
$match = $this->router->match($request); $match = $this->router->match($request);
} catch (MethodNotAllowedException) { } catch (MethodNotAllowedException) {

View file

@ -30,6 +30,15 @@ final class Response
return new self($status, ['error' => $message]); 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 * @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und
* schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife). * 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 // liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen
// gueltigen API-Key, ein offener Origin ist daher unkritisch. // gueltigen API-Key, ein offener Origin ist daher unkritisch.
header('Access-Control-Allow-Origin: *'); 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) { if ($this->streamWriter !== null) {
http_response_code($this->status); http_response_code($this->status);
@ -80,6 +96,11 @@ final class Response
} }
http_response_code($this->status); http_response_code($this->status);
if ($this->status === 204) {
return;
}
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }

View file

@ -132,6 +132,18 @@ final class ApplicationTest extends TestCase
self::assertIsArray($response->body()['repos']); 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 public function testCreateRepoConfigRequiresAdminKey(): void
{ {
$response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [ $response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [