deskCount()); $tickets = new TicketRepository($db); $events = new EventRepository($db); $systems = new SystemRepository($db); $kpis = new KpiRepository($db); $repoConfigs = new RepoConfigRepository($db); $hooks = new HookCounter($config->hooksCounterPath()); $this->agentAuth = new AgentAuthenticator($agents); $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); $this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret()); $agentController = new AgentController($agents); // Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container, // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. $codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()); $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs, $kpis); $ticketController = new TicketController($tickets, $syncService); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); $repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient)); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks); $streamController = new StreamController( new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks), $config->sseMaxIterations(), $config->ssePollIntervalSeconds(), ); $this->router = new Router(); $this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r)); $this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent'); $this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent'); // Issue #51: schreibend wie die Projekte-Verwaltung (Admin-Key), da ein manueller // Voll-Reset des Kanban-Boards potenziell teuer ist (mehrere Codeberg-Requests) und // kein normaler Agenten-Vorgang. $this->router->add('POST', '/api/v1/tickets/resync', fn (Request $r, array $p) => $ticketController->resync($r), 'admin'); $this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r)); $this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent'); $this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r)); $this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent'); $this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r)); $this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent'); // Issue #27: Welche Codeberg-Projekte der Sync liest, ist ueber ein Modal im Frontend // (Klick auf den PO) editierbar - schreibend daher hinter dem Admin-Key wie die // Agenten-Registrierung, nicht hinter einem Agenten-Key (kein Bot-Vorgang). $this->router->add('GET', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->index($r)); $this->router->add('POST', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->create($r), 'admin'); $this->router->add('PATCH', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->update($r, $p), 'admin'); $this->router->add('DELETE', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->delete($r, $p), 'admin'); // Issue #53: Konfig-Pruefung (Schreibrechte + Webhook) nach dem Anlegen/Bearbeiten eines // Projekts im Frontend-Modal - schreibend wie die uebrigen Projekte-Endpunkte, da jeder // Aufruf zwei zusaetzliche Codeberg-Requests ausloest. $this->router->add('POST', '/api/v1/repo-configs/{id}/check', fn (Request $r, array $p) => $repoConfigController->check($r, $p), 'admin'); $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r)); $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin'); $this->router->add( 'POST', '/api/v1/agents/{id}/activity', fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent), 'agent' ); // SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits // oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte). $this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r)); // Codeberg-Webhook: kein API-Key, sondern HMAC-Signatur - Codeberg kennt unsere // Keys nicht, signiert den Body aber mit dem im Webhook hinterlegten Secret. $this->router->add( 'POST', '/api/v1/webhooks/codeberg', fn (Request $r, array $p) => $webhookController->codeberg($r), 'webhook' ); // Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, liefert nur die // Anzahl seit dem letzten Start des api-Containers, keine Payload-Details. $this->router->add('GET', '/api/v1/webhooks/stats', fn (Request $r, array $p) => $webhookController->stats($r)); } 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) { return Response::error('Methode nicht erlaubt.', 405); } if ($match === null) { return Response::error('Nicht gefunden.', 404); } $agent = null; if ($match->auth === 'agent') { $agent = $this->agentAuth->authenticate($request); if ($agent === null) { return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401); } } elseif ($match->auth === 'admin') { if (!$this->adminAuth->authenticate($request)) { return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401); } } elseif ($match->auth === 'webhook') { if (!$this->webhookAuth->authenticate($request)) { return Response::error('Ungueltige oder fehlende Webhook-Signatur.', 401); } } try { return ($match->handler)($request, $match->params, $agent); } catch (NotFoundException $e) { return Response::error($e->getMessage(), 404); } catch (InvalidArgumentException $e) { return Response::error($e->getMessage(), 422); } catch (Throwable $e) { // Unerwartete Fehler (z.B. PDOException) duerfen nie als PHP-Fatal-Error mit // Stacktrace und Dateipfaden im Response-Body landen - Details nur ins Log. error_log(sprintf('[monitor-api] %s: %s in %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine())); return Response::error('Interner Serverfehler.', 500); } } }