hasTable('audit_logs'); } catch (Throwable) { return false; } } public function log( string $action, string $actorType = 'web', ?string $actorUsername = null, ?string $actorClient = null, ?string $resource = null, ?Request $request = null, ?int $statusCode = null, array $metadata = [] ): void { if (! $this->isAvailable()) { return; } try { $ipInfo = $request ? $this->clientIpInfo($request) : ['ip' => null, 'scope' => 'unknown']; $metadata = $this->enrichMetadata($request, $metadata, $ipInfo['scope']); if ($request?->header('X-Forwarded-For')) { $metadata['forwarded_for'] = $request->header('X-Forwarded-For'); } AuditLog::query()->create([ 'occurred_at' => now(), 'actor_type' => $actorType, 'actor_username' => $actorUsername, 'actor_client' => $actorClient, 'action' => $action, 'resource' => $resource, 'method' => $request?->method(), 'path' => $request ? $request->path() : null, 'status_code' => $statusCode, 'ip_address' => $ipInfo['ip'], 'user_agent' => $request?->userAgent(), 'metadata' => $metadata, ]); } catch (Throwable) { // Audit must not break main flow. } } public function logWeb(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void { $this->log( action: $action, actorType: 'web', actorUsername: session('ldap_username'), resource: $resource, request: $request ?? request(), metadata: $metadata ); } public function logPublic(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void { $this->log( action: $action, actorType: 'web', actorUsername: $metadata['username'] ?? null, resource: $resource, request: $request ?? request(), metadata: $metadata ); } public function crudActionFromRoute(Request $request): string { $routeName = $request->route()?->getName() ?? 'web.unknown'; return $routeName; } public function crudVerb(string $method): string { return match (strtoupper($method)) { 'POST' => 'create', 'PUT', 'PATCH' => 'update', 'DELETE' => 'delete', 'GET' => 'read', default => strtolower($method), }; } public function resourceFromRequest(Request $request): ?string { $params = $request->route()?->parameters() ?? []; $parts = []; foreach ($params as $param) { if (is_object($param)) { if (method_exists($param, 'getRouteKey')) { $parts[] = (string) $param->getRouteKey(); } elseif (isset($param->name)) { $parts[] = (string) $param->name; } else { $parts[] = class_basename($param); } } else { $parts[] = (string) $param; } } return $parts !== [] ? implode('/', $parts) : null; } public function clientIpInfo(Request $request): array { $ip = $request->ip(); return [ 'ip' => $ip, 'scope' => $this->classifyIp($ip), ]; } public function classifyIp(?string $ip): string { if ($ip === null || $ip === '') { return 'unknown'; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { if (str_starts_with($ip, '127.') || $ip === '0.0.0.0') { return 'localhost'; } if (preg_match('/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/', $ip)) { return 'private'; } return 'public'; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $lower = strtolower($ip); if ($lower === '::1') { return 'localhost'; } if (str_starts_with($lower, 'fe80:') || str_starts_with($lower, 'fc') || str_starts_with($lower, 'fd')) { return 'private'; } return 'public'; } return 'unknown'; } public function ipScopeLabel(string $scope): string { return match ($scope) { 'public' => 'Publik', 'private' => 'Lokal (LAN)', 'localhost' => 'Localhost', default => 'Tidak diketahui', }; } public function logApi(Request $request, ApiActor $actor, int $statusCode): void { if (! in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { return; } $routeName = $request->route()?->getName() ?? 'api.request'; $this->log( action: $routeName, actorType: 'api', actorUsername: $actor->type === 'ldap' ? $actor->identifier : null, actorClient: $actor->identifier, resource: $this->resourceFromRequest($request), request: $request, statusCode: $statusCode, metadata: [ 'crud' => $this->crudVerb($request->method()), 'auth_type' => $actor->type, 'scopes' => $actor->scopes, 'is_admin' => $actor->isAdmin, 'route' => $routeName, ] ); } public function listRecent( int $limit = 300, ?string $search = null, ?string $actorType = null, ?string $module = null, ?string $ipScope = null ): array { if (! $this->isAvailable()) { return ['rows' => [], 'error' => 'Audit database is not available.']; } $query = AuditLog::query()->orderByDesc('occurred_at')->limit($limit); if ($search !== null && $search !== '') { $needle = '%' . $search . '%'; $query->where(function ($q) use ($needle) { $q->where('actor_username', 'like', $needle) ->orWhere('actor_client', 'like', $needle) ->orWhere('action', 'like', $needle) ->orWhere('resource', 'like', $needle) ->orWhere('path', 'like', $needle) ->orWhere('ip_address', 'like', $needle); }); } if ($actorType !== null && $actorType !== '') { $query->where('actor_type', $actorType); } if ($module !== null && $module !== '') { $query->where(function ($q) use ($module) { $q->where('action', 'like', $module.'%') ->orWhere('action', 'like', '%.'.$module.'.%') ->orWhere('action', 'like', $module.'.%'); }); } if ($ipScope !== null && $ipScope !== '') { $query->where('metadata->ip_scope', $ipScope); } return [ 'rows' => $query->get()->all(), 'error' => null, ]; } public function enrichMetadata(?Request $request, array $metadata, ?string $ipScope = null): array { if ($ipScope !== null) { $metadata['ip_scope'] = $ipScope; } elseif ($request !== null) { $metadata['ip_scope'] = $this->clientIpInfo($request)['scope']; } if ($request !== null) { $client = $this->clientContext->fromSession($request); if ($client === [] && $request->userAgent()) { $client = $this->clientContext->parseUserAgent($request->userAgent()); } if ($client !== []) { $metadata['client'] = $client; } } return $metadata; } public function logPageView( Request $request, string $pageVisitId, string $path, ?string $title, ?string $referrer, array $clientContext ): void { $this->log( action: 'web.ui.view', actorType: 'web', actorUsername: session('ldap_username'), resource: $path, request: $request, metadata: $this->enrichMetadata($request, [ 'page_visit_id' => $pageVisitId, 'page_title' => $title, 'referrer' => $referrer ? mb_substr($referrer, 0, 500) : null, 'client' => $clientContext, 'event' => 'page_view', ]) ); } public function logPageDuration( Request $request, string $pageVisitId, string $path, int $durationMs, array $clientContext ): void { $durationSeconds = (int) round($durationMs / 1000); $this->log( action: 'web.ui.duration', actorType: 'web', actorUsername: session('ldap_username'), resource: $path, request: $request, metadata: $this->enrichMetadata($request, [ 'page_visit_id' => $pageVisitId, 'duration_ms' => $durationMs, 'duration_seconds' => $durationSeconds, 'client' => $clientContext, 'event' => 'page_duration', ]) ); } /** * @return array{page_views: list, page_durations: list} */ public function uiAccessStats(int $days = 7, int $limit = 15): array { if (! $this->isAvailable()) { return ['page_views' => [], 'page_durations' => []]; } $since = now()->subDays($days); $views = AuditLog::query() ->where('action', 'web.ui.view') ->where('occurred_at', '>=', $since) ->get(['resource', 'path']); $viewCounts = []; foreach ($views as $row) { $path = $row->resource ?? $row->path ?? '/'; $viewCounts[$path] = ($viewCounts[$path] ?? 0) + 1; } arsort($viewCounts); $pageViews = []; foreach (array_slice($viewCounts, 0, $limit, true) as $path => $count) { $pageViews[] = ['path' => $path, 'count' => $count]; } $durations = AuditLog::query() ->where('action', 'web.ui.duration') ->where('occurred_at', '>=', $since) ->get(['resource', 'path', 'metadata']); $durationStats = []; foreach ($durations as $row) { $path = $row->resource ?? $row->path ?? '/'; $seconds = (int) ($row->metadata['duration_seconds'] ?? 0); if (! isset($durationStats[$path])) { $durationStats[$path] = ['total' => 0, 'count' => 0]; } $durationStats[$path]['total'] += $seconds; $durationStats[$path]['count']++; } $pageDurations = []; foreach ($durationStats as $path => $stat) { $pageDurations[] = [ 'path' => $path, 'visits' => $stat['count'], 'avg_seconds' => $stat['count'] > 0 ? round($stat['total'] / $stat['count'], 1) : 0, 'total_seconds' => $stat['total'], ]; } usort($pageDurations, fn ($a, $b) => $b['total_seconds'] <=> $a['total_seconds']); return [ 'page_views' => $pageViews, 'page_durations' => array_slice($pageDurations, 0, $limit), ]; } public function listForUser(string $username, int $limit = 50): array { if (! $this->isAvailable()) { return ['rows' => [], 'error' => 'Audit database is not available.']; } $needle = '%' . $username . '%'; $rows = AuditLog::query() ->where(function ($q) use ($username, $needle) { $q->where('actor_username', $username) ->orWhere('resource', $username) ->orWhere('resource', 'like', $needle) ->orWhere('metadata->username', $username); }) ->orderByDesc('occurred_at') ->limit($limit) ->get() ->all(); return ['rows' => $rows, 'error' => null]; } }