$message]); } /** * Klartext-Antwort: $body geht unveraendert raus, ohne JSON-Kodierung. Issue #70: Der * Ping-Check holt seine hosts.conf ueber die API und schreibt den Body direkt in die * Datei - eine JSON-Huelle muesste er in Shell wieder aufdroeseln, was bei jeder * unerwarteten Zeile die Ueberwachungsliste beschaedigen koennte. */ public static function text(string $body, int $status = 200): self { return new self($status, $body, null, 'text/plain; charset=utf-8'); } /** * Rohe Bytes mit beliebigem Content-Type (Issue #85: GET /api/v1/recordings/{id}/audio * liefert die aufgenommene Audiodatei unveraendert, keine JSON-Huelle). * * @param array $headers - zusaetzliche Header (z.B. Content-Disposition). */ public static function binary(string $body, string $contentType, int $status = 200, array $headers = []): self { return new self($status, $body, null, $contentType, $headers); } /** * 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). */ public static function stream(\Closure $writer): self { return new self(200, null, $writer); } public function isStream(): bool { return $this->streamWriter !== null; } /** Fuer Tests: den Stream-Callback ohne echte HTTP-Header/Output-Handling ausfuehren. */ public function writer(): ?\Closure { return $this->streamWriter; } public function send(): void { // Frontend (html/, Port 8090 im Docker-Compose-Setup) und API (Port 8091) laufen auf // unterschiedlichen Origins - ohne CORS wuerde der Browser sowohl die fetch()-Aufrufe // aus office-api.js als auch den EventSource-Stream (F13) blockieren. Alle Endpunkte // 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); header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); // Verhindert, dass ein vorgeschalteter nginx die Antwort puffert und SSE-Events // erst nach Verbindungsende ausliefert. header('X-Accel-Buffering: no'); if (function_exists('ob_implicit_flush')) { ob_implicit_flush(true); } while (ob_get_level() > 0) { ob_end_flush(); } ($this->streamWriter)(); return; } http_response_code($this->status); if ($this->status === 204) { return; } if ($this->contentType !== null) { header('Content-Type: ' . $this->contentType); foreach ($this->extraHeaders as $name => $value) { header($name . ': ' . $value); } echo (string) $this->data; return; } header('Content-Type: application/json; charset=utf-8'); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } public function body(): mixed { return $this->data; } }