Prechádzať zdrojové kódy

feat(ui): wire topnav search to /app/search results page

The topnav "Search IPs" input was a disabled placeholder. Make it a
working search form that aggregates results from the IPs list, manual
blocks, and allowlist into a single results page, with deep-links into
each section's index (the IPs link carries `?q=` so the user lands on
a pre-filtered list).

Manual-blocks and allowlist filtering happens in the controller because
those API endpoints don't accept a `q` parameter; the input is bounded
to 64 chars and substring-matched against `ip` and `cidr` fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 týždeň pred
rodič
commit
956d20e03b

+ 169 - 0
ui/resources/views/pages/search/index.twig

@@ -0,0 +1,169 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Search — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-baseline justify-between gap-4">
+        <h1 class="text-2xl font-semibold tracking-tight">Search</h1>
+        {% if has_query %}
+            <span class="text-sm text-slate-500 dark:text-slate-400">
+                Query: <span class="font-mono text-slate-700 dark:text-slate-200">{{ query }}</span>
+            </span>
+        {% endif %}
+    </div>
+
+    <form method="get" action="/app/search" class="mt-4">
+        <label for="search-q" class="sr-only">Search IPs</label>
+        <div class="flex gap-2">
+            <input type="search" id="search-q" name="q" value="{{ query }}"
+                   placeholder="Enter an IP, prefix, or CIDR (e.g. 203.0.113 or 10.0.0.0/8)"
+                   class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-mono dark:border-slate-700 dark:bg-slate-900"
+                   autofocus>
+            <button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Search</button>
+        </div>
+    </form>
+
+    {% if not has_query %}
+        <p class="mt-6 text-sm text-slate-500 dark:text-slate-400">
+            Enter an IP address or prefix to search across the IPs list, manual blocks, and allowlist.
+        </p>
+    {% else %}
+        {# ------------------------------- IPs --------------------------------- #}
+        <section class="mt-8">
+            <div class="flex items-center justify-between">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
+                    IPs <span class="ml-2 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ ips ? ips.total : 0 }}</span>
+                </h2>
+                <a href="/app/ips?q={{ query|url_encode }}" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">
+                    Open in IPs &rsaquo;
+                </a>
+            </div>
+            {% if errors.ips is defined %}
+                <div class="mt-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
+                    Failed to load IPs: {{ errors.ips }}
+                </div>
+            {% elseif ips and ips.items|length > 0 %}
+                <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                    <table class="w-full text-sm">
+                        <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                            <tr>
+                                <th class="px-4 py-2 font-medium">IP</th>
+                                <th class="px-4 py-2 font-medium">Top category</th>
+                                <th class="px-4 py-2 text-right font-medium">Max score</th>
+                                <th class="px-4 py-2 font-medium">Last report</th>
+                                <th class="px-4 py-2 font-medium">Status</th>
+                            </tr>
+                        </thead>
+                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                            {% for item in ips.items %}
+                                <tr>
+                                    <td class="px-4 py-2"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
+                                    <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
+                                    <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.lastReportAt|default('—') }}</td>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.status }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+                {% if ips.total > ips.items|length %}
+                    <p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
+                        Showing {{ ips.items|length }} of {{ ips.total }} —
+                        <a href="/app/ips?q={{ query|url_encode }}" class="text-indigo-600 hover:underline dark:text-indigo-400">view all</a>.
+                    </p>
+                {% endif %}
+            {% else %}
+                <p class="mt-3 text-sm text-slate-500 dark:text-slate-400">No IPs match this query.</p>
+            {% endif %}
+        </section>
+
+        {# --------------------------- Manual blocks --------------------------- #}
+        <section class="mt-8">
+            <div class="flex items-center justify-between">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
+                    Manual blocks <span class="ml-2 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ manual_blocks|length }}</span>
+                </h2>
+                <a href="/app/manual-blocks" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">
+                    Open Manual blocks &rsaquo;
+                </a>
+            </div>
+            {% if errors.manual_blocks is defined %}
+                <div class="mt-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
+                    Failed to load manual blocks: {{ errors.manual_blocks }}
+                </div>
+            {% elseif manual_blocks|length > 0 %}
+                <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                    <table class="w-full text-sm">
+                        <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                            <tr>
+                                <th class="px-4 py-2 font-medium">Kind</th>
+                                <th class="px-4 py-2 font-medium">Target</th>
+                                <th class="px-4 py-2 font-medium">Reason</th>
+                                <th class="px-4 py-2 font-medium">Expires</th>
+                                <th class="px-4 py-2 font-medium">Created</th>
+                            </tr>
+                        </thead>
+                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                            {% for item in manual_blocks %}
+                                <tr>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
+                                    <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.expires_at|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            {% else %}
+                <p class="mt-3 text-sm text-slate-500 dark:text-slate-400">No manual blocks match this query.</p>
+            {% endif %}
+        </section>
+
+        {# ------------------------------ Allowlist --------------------------- #}
+        <section class="mt-8">
+            <div class="flex items-center justify-between">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
+                    Allowlist <span class="ml-2 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ allowlist|length }}</span>
+                </h2>
+                <a href="/app/allowlist" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">
+                    Open Allowlist &rsaquo;
+                </a>
+            </div>
+            {% if errors.allowlist is defined %}
+                <div class="mt-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
+                    Failed to load allowlist: {{ errors.allowlist }}
+                </div>
+            {% elseif allowlist|length > 0 %}
+                <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                    <table class="w-full text-sm">
+                        <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                            <tr>
+                                <th class="px-4 py-2 font-medium">Kind</th>
+                                <th class="px-4 py-2 font-medium">Target</th>
+                                <th class="px-4 py-2 font-medium">Reason</th>
+                                <th class="px-4 py-2 font-medium">Created</th>
+                            </tr>
+                        </thead>
+                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                            {% for item in allowlist %}
+                                <tr>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
+                                    <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            {% else %}
+                <p class="mt-3 text-sm text-slate-500 dark:text-slate-400">No allowlist entries match this query.</p>
+            {% endif %}
+        </section>
+    {% endif %}
+</div>
+{% endblock %}

+ 9 - 4
ui/resources/views/partials/topnav.twig

@@ -5,10 +5,15 @@
             <span class="hidden text-xs text-slate-500 md:inline">IP Reputation Database</span>
         </div>
         <div class="flex flex-1 items-center justify-end gap-3">
-            <input type="search"
-                   placeholder="Search IPs… (M09)"
-                   disabled
-                   class="hidden w-64 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 md:block" />
+            <form method="get" action="/app/search" class="hidden md:block" role="search">
+                <label for="topnav-search" class="sr-only">Search IPs</label>
+                <input type="search"
+                       id="topnav-search"
+                       name="q"
+                       placeholder="Search IPs…"
+                       maxlength="64"
+                       class="w-64 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900" />
+            </form>
             <button type="button"
                     data-theme-toggle
                     aria-label="Toggle theme"

+ 5 - 0
ui/src/App/AppFactory.php

@@ -20,6 +20,7 @@ use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Controllers\PoliciesController;
 use App\Controllers\ReportersController;
+use App\Controllers\SearchController;
 use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
@@ -187,6 +188,10 @@ final class AppFactory
             $audit = $container->get(AuditController::class);
             $group->get('/audit', [$audit, 'index']);
 
+            /** @var SearchController $search */
+            $search = $container->get(SearchController::class);
+            $group->get('/search', [$search, 'index']);
+
             /** @var SettingsController $settings */
             $settings = $container->get(SettingsController::class);
             $group->get('/settings', [$settings, 'index']);

+ 2 - 0
ui/src/App/Container.php

@@ -28,6 +28,7 @@ use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Controllers\PoliciesController;
 use App\Controllers\ReportersController;
+use App\Controllers\SearchController;
 use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
@@ -215,6 +216,7 @@ final class Container
             TokensController::class => autowire(),
             CategoriesController::class => autowire(),
             AuditController::class => autowire(),
+            SearchController::class => autowire(),
             SettingsController::class => autowire(),
 
             LoginThrottle::class => factory(static function (ContainerInterface $c): LoginThrottle {

+ 121 - 0
ui/src/Controllers/SearchController.php

@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/search` — global IP lookup driven by the topnav search box.
+ *
+ * Aggregates results from three API surfaces:
+ *   • `/api/v1/admin/ips` (uses native `q` prefix filter)
+ *   • `/api/v1/admin/manual-blocks` (filtered client-side; api has no `q`)
+ *   • `/api/v1/admin/allowlist`     (same)
+ *
+ * The IPs list page already supports `?q=`; the manual-blocks and
+ * allowlist pages do not — the UI link drops the user on the section's
+ * index where the matched entries are visible.
+ */
+final class SearchController
+{
+    private const MAX_QUERY_LENGTH = 64;
+
+    public function __construct(
+        private readonly Twig $twig,
+        private readonly SessionManager $sessions,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $user = $this->sessions->getUser();
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+
+        $raw = $request->getQueryParams()['q'] ?? null;
+        $query = is_string($raw) ? trim($raw) : '';
+        if ($query === '') {
+            return $this->twig->render($response, 'pages/search/index.twig', [
+                'active_section' => null,
+                'query' => '',
+                'has_query' => false,
+                'ips' => null,
+                'manual_blocks' => [],
+                'allowlist' => [],
+                'errors' => [],
+            ]);
+        }
+        if (strlen($query) > self::MAX_QUERY_LENGTH) {
+            $query = substr($query, 0, self::MAX_QUERY_LENGTH);
+        }
+
+        $ips = null;
+        $manualBlocks = [];
+        $allowlist = [];
+        $errors = [];
+
+        try {
+            $ips = $this->admin->searchIps($user->userId, ['q' => $query], 1, 25);
+        } catch (ApiException $e) {
+            $errors['ips'] = $e->getMessage();
+        }
+
+        try {
+            $manualPayload = $this->admin->listManualBlocks($user->userId, null);
+            $manualBlocks = $this->filterEntries($manualPayload, $query);
+        } catch (ApiException $e) {
+            $errors['manual_blocks'] = $e->getMessage();
+        }
+
+        try {
+            $allowPayload = $this->admin->listAllowlist($user->userId, null);
+            $allowlist = $this->filterEntries($allowPayload, $query);
+        } catch (ApiException $e) {
+            $errors['allowlist'] = $e->getMessage();
+        }
+
+        return $this->twig->render($response, 'pages/search/index.twig', [
+            'active_section' => null,
+            'query' => $query,
+            'has_query' => true,
+            'ips' => $ips,
+            'manual_blocks' => $manualBlocks,
+            'allowlist' => $allowlist,
+            'errors' => $errors,
+        ]);
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     * @return list<array<string, mixed>>
+     */
+    private function filterEntries(array $payload, string $query): array
+    {
+        $items = $payload['items'] ?? [];
+        if (!is_array($items)) {
+            return [];
+        }
+        $needle = strtolower($query);
+        $out = [];
+        foreach ($items as $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $haystack = strtolower((string) ($item['ip'] ?? '')) . ' ' . strtolower((string) ($item['cidr'] ?? ''));
+            if ($haystack !== ' ' && str_contains($haystack, $needle)) {
+                $out[] = $item;
+            }
+        }
+
+        return $out;
+    }
+}

+ 124 - 0
ui/tests/Integration/Search/SearchPageTest.php

@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Search;
+
+use App\Auth\UserContext;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * `/app/search` — global IP lookup behind the topnav search box.
+ *
+ * The controller fans out to three admin endpoints in this order:
+ *   1. GET /api/v1/admin/ips
+ *   2. GET /api/v1/admin/manual-blocks
+ *   3. GET /api/v1/admin/allowlist
+ *
+ * Tests queue mock responses for that exact sequence.
+ */
+final class SearchPageTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+        // The first /app/* request in a process triggers session_start(),
+        // which would clobber any $_SESSION values set here. Hit a public
+        // route first so the session is already active by the time our
+        // test request fires.
+        $this->request('GET', '/healthz');
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+    }
+
+    public function testRendersEmptyFormWithoutQuery(): void
+    {
+        $resp = $this->request('GET', '/app/search');
+
+        self::assertSame(200, $resp->getStatusCode());
+        $body = (string) $resp->getBody();
+        self::assertStringContainsString('Enter an IP address or prefix', $body);
+        self::assertStringContainsString('name="q"', $body);
+    }
+
+    public function testRendersResultsForQuery(): void
+    {
+        // 1) IPs
+        $this->enqueueApiResponse(200, [
+            'page' => 1,
+            'page_size' => 25,
+            'total' => 1,
+            'items' => [
+                [
+                    'ip' => '203.0.113.42',
+                    'is_ipv4' => true,
+                    'max_score' => 4.5,
+                    'top_category' => 'brute_force',
+                    'pair_count' => 1,
+                    'last_report_at' => '2026-04-29T09:00:00Z',
+                    'status' => 'scored',
+                    'enrichment' => null,
+                ],
+            ],
+        ]);
+        // 2) Manual blocks
+        $this->enqueueApiResponse(200, [
+            'items' => [
+                ['id' => 1, 'kind' => 'subnet', 'cidr' => '203.0.113.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
+                ['id' => 2, 'kind' => 'ip', 'ip' => '198.51.100.7', 'reason' => 'unrelated', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
+            ],
+            'total' => 2,
+        ]);
+        // 3) Allowlist
+        $this->enqueueApiResponse(200, [
+            'items' => [
+                ['id' => 9, 'kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'office', 'created_at' => '2026-04-28T10:00:00Z', 'created_by_user_id' => null],
+            ],
+            'total' => 1,
+        ]);
+
+        $resp = $this->request('GET', '/app/search?q=203.0.113');
+
+        self::assertSame(200, $resp->getStatusCode());
+        $body = (string) $resp->getBody();
+
+        // IP result row + link to filtered IPs page.
+        self::assertStringContainsString('203.0.113.42', $body);
+        self::assertStringContainsString('/app/ips?q=203.0.113', $body);
+        // Manual block row that matched the query.
+        self::assertStringContainsString('203.0.113.0/24', $body);
+        // Unrelated manual-block entry must not render.
+        self::assertStringNotContainsString('198.51.100.7', $body);
+        // Section links to the index pages.
+        self::assertStringContainsString('href="/app/manual-blocks"', $body);
+        self::assertStringContainsString('href="/app/allowlist"', $body);
+    }
+
+    public function testHandlesPartialApiFailureGracefully(): void
+    {
+        // IPs succeeds, manual-blocks 500s (twice — ApiClient retries on 5xx),
+        // allowlist succeeds.
+        $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 25, 'total' => 0, 'items' => []]);
+        $this->enqueueApiResponse(500, ['error' => 'boom']);
+        $this->enqueueApiResponse(500, ['error' => 'boom']);
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
+
+        $resp = $this->request('GET', '/app/search?q=10.0.0.1');
+
+        self::assertSame(200, $resp->getStatusCode());
+        $body = (string) $resp->getBody();
+        self::assertStringContainsString('Failed to load manual blocks', $body);
+        // The other sections still render their no-results state.
+        self::assertStringContainsString('No IPs match this query.', $body);
+        self::assertStringContainsString('No allowlist entries match this query.', $body);
+    }
+
+    public function testRedirectsAnonymousToLogin(): void
+    {
+        $_SESSION = [];
+        $resp = $this->request('GET', '/app/search?q=foo');
+        self::assertSame(302, $resp->getStatusCode());
+        self::assertSame('/login', $resp->getHeaderLine('Location'));
+    }
+}