repo = new Repository($db); $this->auth = new AuthMiddleware($this->repo); $this->engine = new Engine($this->repo); $this->telegram = new TelegramNotifier($this->repo); } private function user(): ?string { return $this->currentUser; } public function handle(): void { $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = rtrim($path, '/'); $path = $path ?: '/'; header('Content-Type: application/json'); try { $publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf']; $isPublic = false; foreach ($publicPaths as $pp) { if ($path === $pp || str_starts_with($path, $pp . '/')) { $isPublic = true; break; } } if (!$isPublic) { $user = $this->auth->requireAuth(); if (!$user) { http_response_code(401); echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]); return; } $this->currentUser = $user['username'] ?? null; } $result = match (true) { $path === '/health' && $method === 'GET' => $this->health(), $path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(), $path === '/ingest' && $method === 'POST' => $this->requireCsrf(fn() => $this->ingest()), $path === '/auth/me' && $method === 'GET' => $this->getMe(), $path === '/auth/logout' && $method === 'POST' => $this->logout(), $path === '/sources' && $method === 'GET' => $this->repo->getSources(), $path === '/sources' && $method === 'POST' => $this->requireCsrf(fn() => $this->createSource()), preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE' => $this->requireCsrf(fn() => $this->deleteEntity('source', (int) $m[1])), preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'PUT' => $this->requireCsrf(fn() => $this->updateSource((int) $m[1])), $path === '/rules' && $method === 'GET' => $this->repo->getRules(), $path === '/rules' && $method === 'POST' => $this->requireCsrf(fn() => $this->createRule()), $path === '/rules/test' && $method === 'POST' => $this->requireCsrf(fn() => $this->testRule()), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE' => $this->requireCsrf(fn() => $this->deleteEntity('rule', (int) $m[1])), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'PUT' => $this->requireCsrf(fn() => $this->updateRule((int) $m[1])), $path === '/alerts' && $method === 'GET' => $this->getAlerts(), $path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(), $path === '/alerts/bulk' && $method === 'POST' => $this->requireCsrf(fn() => $this->bulkAlertAction()), preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->requireCsrf(fn() => $this->ackAlert((int) $m[1])), preg_match('#^/alerts/(\d+)/status$#', $path, $m) && $method === 'POST' => $this->requireCsrf(fn() => $this->updateAlertStatus((int) $m[1])), preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(), preg_match('#^/alerts/export$#', $path) && $method === 'POST' => $this->requireCsrf(fn() => $this->exportAlerts()), preg_match('#^/logs/context/(\d+)$#', $path, $m) && $method === 'GET' => $this->logContext((int) $m[1]), $path === '/logs/search' && $method === 'GET' => $this->searchLogs(), preg_match('#^/logs/export$#', $path) && $method === 'POST' => $this->requireCsrf(fn() => $this->exportLogs()), $path === '/system/retention' && $method === 'POST' => $this->requireCsrf(fn() => $this->runRetention()), $path === '/system/retention' && $method === 'GET' => $this->getRetentionConfig(), $path === '/system/audit-log' && $method === 'GET' => $this->getAuditLog(), $path === '/config/allowed_tokens' && $method === 'GET' => ['tokens' => $this->repo->getAllowedUserTokens()], $path === '/config/allowed_tokens' && $method === 'PUT' => $this->requireCsrf(fn() => $this->updateAllowedTokens()), $path === '/config/telegram' && $method === 'GET' => $this->getTelegramConfig(), $path === '/config/telegram' && $method === 'PUT' => $this->requireCsrf(fn() => $this->updateTelegramConfig()), $path === '/false-positives' && $method === 'GET' => $this->getFalsePositives(), $path === '/false-positives' && $method === 'POST' => $this->requireCsrf(fn() => $this->createFalsePositive()), preg_match('#^/false-positives/(\d+)$#', $path, $m) && $method === 'DELETE' => $this->requireCsrf(fn() => $this->deleteFalsePositive((int) $m[1])), default => throw new \RuntimeException('Not found', 404), }; $this->respond(200, $result); } catch (\Throwable $e) { $code = is_int($e->getCode()) && $e->getCode() >= 100 && $e->getCode() < 600 ? $e->getCode() : 500; $this->respond($code, ['error' => $e->getMessage()]); } } private function respond(int $code, mixed $result): void { http_response_code($code); if (is_array($result)) { $hasObjects = false; $isList = array_is_list($result); foreach ($result as $key => $val) { if (is_object($val) && method_exists($val, 'toArray')) { $result[$key] = $val->toArray(); $hasObjects = true; } } if ($hasObjects || ($isList && (empty($result) || !isset($result['data'])))) { if ($isList) { $result = ['data' => $result]; } } } elseif (is_object($result) && method_exists($result, 'toArray')) { $result = ['data' => $result->toArray()]; } echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } private function health(): array { $sqliteOk = true; $clickhouseOk = true; $dbSize = 'unknown'; try { $this->repo->getAlerts(1); $dbPath = '/app/data/logging.db'; if (file_exists($dbPath)) { $bytes = filesize($dbPath); $dbSize = $bytes > 1073741824 ? round($bytes / 1073741824, 1) . ' GB' : ($bytes > 1048576 ? round($bytes / 1048576, 1) . ' MB' : round($bytes / 1024, 1) . ' KB'); } } catch (\Throwable) { $sqliteOk = false; } try { $this->repo->clickhouse()->query('SELECT 1'); } catch (\Throwable) { $clickhouseOk = false; } $allOk = $sqliteOk && $clickhouseOk; return [ 'status' => $allOk ? 'ok' : 'degraded', 'sqlite' => $sqliteOk ? 'connected' : 'error', 'clickhouse' => $clickhouseOk ? 'connected' : 'error', 'db_size' => $dbSize, 'time' => date('c'), ]; } private function loginUrl(): string { $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; if (!$this->isTrustedHost($host)) { $host = 'localhost'; } $redirect = 'https://' . $host . '/oauth.php'; return 'https://auth.jakach.ch/?send_to=' . urlencode($redirect); } private function isTrustedHost(string $host): bool { $host = strtolower(preg_replace('/:\d+$/', '', $host)); return $host === 'localhost' || $host === '127.0.0.1' || $host === 'jakach.ch' || str_ends_with($host, '.jakach.ch'); } private function getMe(): array { $user = $this->auth->requireAuth(); if (!$user) { http_response_code(401); return ['error' => 'Not logged in', 'login_url' => $this->loginUrl()]; } return ['user' => $user]; } private function logout(): array { if (session_status() === PHP_SESSION_NONE) { session_start(); } $_SESSION = []; if (ini_get("session.use_cookies")) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params["path"], $params["domain"], $params["secure"], $params["httponly"] ); } session_destroy(); return ['status' => 'logged_out']; } private function createSource(): mixed { $body = json_decode(file_get_contents('php://input'), true); $type = LogSourceType::from($body['type'] ?? ''); $result = $this->repo->createSource( name: $body['name'], type: $type, address: $body['address'], labels: $body['labels'] ?? [], ); $this->repo->logAudit('create', 'source', $result->id, 'Source "' . ($body['name'] ?? '') . '" created', $this->user()); return $result; } private function createRule(): mixed { $body = json_decode(file_get_contents('php://input'), true); $result = $this->repo->createRule( name: $body['name'], pattern: $body['pattern'], severity: $body['severity'] ?? 'warning', rateLimitSeconds: $body['rate_limit_seconds'] ?? null, ); $this->repo->logAudit('create', 'rule', $result->id, 'Rule "' . ($body['name'] ?? '') . '" created', $this->user()); return $result; } private function deleteEntity(string $type, int $id): array { $name = ''; if ($type === 'source') { $s = $this->repo->getSource($id); $name = $s ? $s->name : ''; $this->repo->deleteSource($id); } else { $r = $this->repo->getRule($id); $name = $r ? $r->name : ''; $this->repo->deleteRule($id); } $this->repo->logAudit('delete', $type, $id, ucfirst($type) . ' "' . $name . '" deleted', $this->user()); return ['status' => 'deleted', 'id' => $id]; } private function updateSource(int $id): mixed { $body = json_decode(file_get_contents('php://input'), true); $type = LogSourceType::from($body['type'] ?? ''); return $this->repo->updateSource( id: $id, name: $body['name'], type: $type, address: $body['address'], labels: $body['labels'] ?? [], active: $body['active'] ?? true, ); } private function updateRule(int $id): mixed { $body = json_decode(file_get_contents('php://input'), true); $result = $this->repo->updateRule( id: $id, name: $body['name'], pattern: $body['pattern'], severity: $body['severity'] ?? 'warning', rateLimitSeconds: $body['rate_limit_seconds'] ?? null, active: $body['active'] ?? true, ); $this->repo->logAudit('update', 'rule', $id, 'Rule "' . ($body['name'] ?? '') . '" updated', $this->user()); return $result; } private function getAlerts(): mixed { $limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500); $offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000); $status = $_GET['status'] ?? null; $severity = $_GET['severity'] ?? null; return $this->repo->getAlerts($limit, $offset, $status, $severity, $_GET['since'] ?? null, $_GET['until'] ?? null); } private function ackAlert(int $id): array { $this->repo->updateAlertStatus($id, AlertStatus::Acknowledged); $this->repo->logAudit('acknowledge', 'alert', $id, null, $this->user()); return ['status' => 'acknowledged', 'id' => $id]; } private function updateAlertStatus(int $id): array { $body = json_decode(file_get_contents('php://input'), true); $status = AlertStatus::tryFrom($body['status'] ?? ''); if (!$status) { http_response_code(400); return ['error' => 'Invalid status. Use: open, acknowledged, resolved']; } $this->repo->updateAlertStatus($id, $status); $this->repo->logAudit('status_change', 'alert', $id, 'Status set to ' . $status->value, $this->user()); return ['status' => $status->value, 'id' => $id]; } private function searchAlerts(): array { $query = $_GET['q'] ?? ''; if (empty($query)) { return []; } $limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500); return $this->repo->searchAlerts($query, $limit); } private function searchLogs(): array { $query = $_GET['q'] ?? ''; if (empty($query)) { return ['data' => []]; } $limit = $this->boundedInt($_GET['limit'] ?? 200, 1, 1000); $offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000); return ['data' => $this->repo->searchLogEntries($query, $limit, $offset, $_GET['since'] ?? null, $_GET['until'] ?? null)]; } private function updateAllowedTokens(): array { $body = json_decode(file_get_contents('php://input'), true); $tokens = $body['tokens'] ?? []; $this->repo->setAllowedUserTokens($tokens); $this->repo->logAudit('update', 'config', null, 'Allowed tokens updated', $this->user()); return ['status' => 'saved', 'tokens' => $this->repo->getAllowedUserTokens()]; } private function ingest(): array { $body = json_decode(file_get_contents('php://input'), true); $line = $body['line'] ?? ''; $source = $body['source'] ?? 'http'; if (empty($line)) { http_response_code(400); return ['error' => 'Missing "line" field']; } if (strlen($line) > 65535) { http_response_code(413); return ['error' => 'Log line too large']; } $alert = $this->engine->evaluate($line, null); if ($alert !== null) { $this->telegram->send($alert); } return ['status' => 'ingested', 'line' => substr($line, 0, 100)]; } private function getTelegramConfig(): array { $token = $this->repo->getConfig('telegram_bot_token', ''); $masked = strlen($token) > 8 ? substr($token, 0, 8) . '...' : ($token ? substr($token, 0, 3) . '...' : ''); return [ 'bot_token_masked' => $masked, 'bot_token_configured' => $token !== '', 'chat_id' => $this->repo->getConfig('telegram_chat_id', ''), ]; } private function updateTelegramConfig(): array { $body = json_decode(file_get_contents('php://input'), true) ?: []; if (array_key_exists('bot_token', $body) && trim((string) $body['bot_token']) !== '') { $this->repo->setConfig('telegram_bot_token', trim((string) $body['bot_token'])); } $this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? ''); $this->repo->logAudit('update', 'config', null, 'Telegram config updated', $this->user()); return $this->getTelegramConfig(); } private function createFalsePositive(): array { $body = json_decode(file_get_contents('php://input'), true); $pattern = $body['pattern'] ?? ''; $description = $body['description'] ?? ''; if (empty($pattern)) { http_response_code(400); return ['error' => 'Missing "pattern" field']; } return $this->repo->createFalsePositive($pattern, $description); } private function getFalsePositives(): array { return ['data' => $this->repo->getFalsePositives()]; } private function deleteFalsePositive(int $id): array { $this->repo->deleteFalsePositive($id); $this->repo->logAudit('delete', 'false_positive', $id, null, $this->user()); return ['status' => 'deleted', 'id' => $id]; } private function csrfToken(): array { $this->auth->requireAuth(); return ['csrf_token' => $_SESSION['csrf_token'] ?? '']; } private function requireCsrf(callable $fn): mixed { if (!$this->auth->validateCsrf()) { http_response_code(403); return ['error' => 'Invalid or missing CSRF token']; } return $fn(); } // --- New Handlers --- private function testRule(): array { $body = json_decode(file_get_contents('php://input'), true); $line = $body['line'] ?? ''; if (empty($line)) { http_response_code(400); return ['error' => 'Missing "line" field']; } $rules = $this->repo->getActiveRules(); $matches = []; foreach ($rules as $rule) { $delimiter = $rule->pattern[0] ?? '/'; if (preg_match($rule->pattern, $line)) { $matches[] = [ 'rule_id' => $rule->id, 'rule_name' => $rule->name, 'severity' => $rule->severity->value, 'is_false_positive' => $this->repo->isFalsePositive($line), ]; } } return ['data' => $matches, 'line' => substr($line, 0, 200)]; } private function bulkAlertAction(): array { $body = json_decode(file_get_contents('php://input'), true); $ids = $body['ids'] ?? []; $status = AlertStatus::tryFrom($body['status'] ?? ''); if (empty($ids) || !$status) { http_response_code(400); return ['error' => 'Missing "ids" array or valid "status"']; } $count = $this->repo->bulkUpdateAlertStatus($ids, $status); $this->repo->logAudit('bulk_status', 'alert', null, count($ids) . ' alerts set to ' . $status->value, $this->user()); return ['updated' => $count]; } private function logContext(int $id): array { $before = (int) ($_GET['before'] ?? 5); $after = (int) ($_GET['after'] ?? 5); return $this->repo->getLogContext($id, min($before, 50), min($after, 50)); } private function exportAlerts(): never { $body = json_decode(file_get_contents('php://input'), true); $ids = $body['ids'] ?? []; $format = $body['format'] ?? 'json'; $alerts = $this->repo->exportAlerts($ids); if ($format === 'csv') { header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="alerts.csv"'); $out = fopen('php://output', 'w'); fputcsv($out, ['ID', 'Rule', 'Severity', 'Status', 'Message', 'Source', 'Created']); foreach ($alerts as $a) { fputcsv($out, [$a->id, $a->ruleName, $a->severity->value, $a->status->value, $a->message, $a->sourceName ?? '', $a->createdAt->format('c')]); } fclose($out); } else { echo json_encode(['data' => array_map(fn($a) => $a->toArray(), $alerts)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } exit; } private function exportLogs(): never { $body = json_decode(file_get_contents('php://input'), true); $ids = $body['ids'] ?? []; $format = $body['format'] ?? 'json'; $logs = $this->repo->exportLogs($ids); if ($format === 'csv') { header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="logs.csv"'); $out = fopen('php://output', 'w'); fputcsv($out, ['ID', 'Line', 'Source', 'Level', 'Created']); foreach ($logs as $l) { fputcsv($out, [$l['id'], $l['line'], $l['source_name'] ?? '', $l['level'] ?? '', $l['created_at']]); } fclose($out); } else { echo json_encode(['data' => $logs], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } exit; } private function runRetention(): array { $body = json_decode(file_get_contents('php://input'), true); $logDays = (int) ($body['log_days'] ?? 30); $alertDays = (int) ($body['alert_days'] ?? 90); $result = $this->repo->purgeOldData($logDays, $alertDays); $this->repo->logAudit('retention_purge', 'system', null, json_encode($result), $this->user()); return $result; } private function getRetentionConfig(): array { return [ 'log_days' => (int) ($this->repo->getConfig('retention_log_days', '30')), 'alert_days' => (int) ($this->repo->getConfig('retention_alert_days', '90')), ]; } private function getAuditLog(): array { $limit = $this->boundedInt($_GET['limit'] ?? 50, 1, 500); return ['data' => $this->repo->getAuditLog($limit)]; } private function boundedInt(mixed $value, int $min, int $max): int { $value = filter_var($value, FILTER_VALIDATE_INT); if ($value === false) { return $min; } return max($min, min($max, $value)); } }