592 lines
22 KiB
PHP
592 lines
22 KiB
PHP
<?php
|
|
|
|
namespace Jakach\Logging\Api;
|
|
|
|
use Jakach\Logging\Model\{LogSourceType, AlertStatus};
|
|
use Jakach\Logging\Notifier\TelegramNotifier;
|
|
use Jakach\Logging\Storage\{Database, Repository};
|
|
use Jakach\Logging\RuleEngine\Engine;
|
|
|
|
class Router
|
|
{
|
|
private Repository $repo;
|
|
private AuthMiddleware $auth;
|
|
private Engine $engine;
|
|
private TelegramNotifier $telegram;
|
|
private ?string $currentUser = null;
|
|
|
|
public function __construct()
|
|
{
|
|
$db = new Database();
|
|
$this->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));
|
|
}
|
|
}
|