Setzt den in #84 skizzierten Workflow um: record.html nimmt im Handy-Browser eine Sprachnotiz per MediaRecorder auf und laedt sie unauthentifiziert zu POST /api/v1/agents/{id}/recordings hoch. Ein Klick auf die Personen-Figur jedes registrierten Agenten in der Buero-Visualisierung verlinkt dorthin, ein Zaehler am Schreibtisch zeigt offene Aufnahmen. Der Agent selbst holt seine Warteschlange ueber die bestehenden Api-Key-authentifizierten Endpunkte ab (GET .../recordings, GET /recordings/{id}/audio) und markiert eine Aufnahme nach der Transkription per PATCH als erledigt - die eigentliche Transkription (whisper.cpp/faster-whisper) bleibt agent-seitig, siehe fds2610/Agent#17. Schliesst #85
150 lines
5.5 KiB
PHP
150 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api\Http;
|
|
|
|
final class Response
|
|
{
|
|
/**
|
|
* @param mixed $data
|
|
* @param \Closure|null $streamWriter - gesetzt fuer SSE-Antworten (F13, Phase 4): statt
|
|
* eines JSON-Bodys schreibt dieser Callback selbst Bytes an den Client (siehe
|
|
* StreamController). $data bleibt in dem Fall null.
|
|
*/
|
|
private function __construct(
|
|
public readonly int $status,
|
|
private readonly mixed $data,
|
|
private readonly ?\Closure $streamWriter = null,
|
|
// Gesetzt fuer Antworten, deren Body kein JSON ist (Issue #70: die hosts.conf, die
|
|
// check_fds.sh direkt in eine Datei schreibt) - dann ist $data der fertige Body.
|
|
private readonly ?string $contentType = null,
|
|
// Zusaetzliche Header ueber Content-Type hinaus (Issue #85: Content-Disposition beim
|
|
// Herunterladen einer Sprachaufnahme).
|
|
private readonly array $extraHeaders = [],
|
|
) {
|
|
}
|
|
|
|
/** @param mixed $data */
|
|
public static function json(mixed $data, int $status = 200): self
|
|
{
|
|
return new self($status, $data);
|
|
}
|
|
|
|
public static function error(string $message, int $status): self
|
|
{
|
|
return new self($status, ['error' => $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<string, string> $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;
|
|
}
|
|
}
|