can('manage-panel'), 403); } /** A changed search must restart at page 1 — never land on an out-of-range page. */ public function updatedQ(): void { $this->resetPage(); } /** * Honeypot / ban events, newest first, optionally filtered. The filter runs in SQL (so it spans * the whole history, not just one page) and matches the source IP and the probed target path. * Mirrors Audit\Index::eventsQuery() but scoped to the honeypot action set. */ protected function eventsQuery(): Builder { $query = AuditEvent::query()->whereIn('action', self::FEED_ACTIONS)->latest(); $needle = trim($this->q); if ($needle === '') { return $query; } $like = '%'.mb_strtolower($needle).'%'; return $query->where(function (Builder $w) use ($like): void { $w->whereRaw('LOWER(ip) LIKE ?', [$like]) ->orWhereRaw('LOWER(target) LIKE ?', [$like]); }); } /** Currently active bans (not yet expired), newest first — a short list for the ban panel. */ protected function bannedIps(): Collection { return BannedIp::active()->latest('banned_until')->limit(50)->get(); } /** * Page numbers to render, with "…" gaps for long histories (1 … 4 5 6 … 20). * * @return array */ protected function pageWindow(int $current, int $total): array { if ($total <= 7) { return range(1, max(1, $total)); } $pages = [1]; $from = max(2, $current - 1); $to = min($total - 1, $current + 1); if ($from > 2) { $pages[] = '…'; } for ($i = $from; $i <= $to; $i++) { $pages[] = $i; } if ($to < $total - 1) { $pages[] = '…'; } $pages[] = $total; return $pages; } /** * Lift an active ban. Reuses the exact unban path + audit code + toast convention from * Settings\LoginProtection so the two management surfaces stay consistent. */ public function unban(string $ip, BruteforceGuard $guard): void { abort_unless(Auth::user()?->can('manage-panel'), 403); $guard->unban($ip); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => 'auth.ip_unbanned', 'target' => $ip, 'ip' => request()->ip(), ]); $this->dispatch('notify', message: __('threats.unbanned_toast', ['ip' => $ip])); } public function render() { /** @var LengthAwarePaginator $events */ $events = $this->eventsQuery()->paginate(self::PER_PAGE); // Most active source across all honeypot/token events (null when there is no traffic yet). $topIp = AuditEvent::query() ->whereIn('action', ['security.honeypot_hit', 'security.honeytoken_used']) ->whereNotNull('ip') ->selectRaw('ip, COUNT(*) as hits') ->groupBy('ip') ->orderByDesc('hits') ->limit(1) ->value('ip'); return view('livewire.threats.index', [ 'events' => $events, 'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()), 'bannedIps' => $this->bannedIps(), 'probes_24h' => AuditEvent::query() ->where('action', 'security.honeypot_hit') ->where('created_at', '>=', now()->subDay()) ->count(), 'banned_total' => BannedIp::active()->count(), 'honeytoken_trips' => AuditEvent::query()->where('action', 'security.honeytoken_used')->count(), 'top_ip' => $topIp, ])->title(__('threats.title')); } }