diff --git a/db/nodes.go b/db/nodes.go index e416c78..81474cd 100644 --- a/db/nodes.go +++ b/db/nodes.go @@ -79,7 +79,7 @@ func (s *Store) SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scope }) } -func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { +func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} @@ -95,6 +95,7 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s Limit: limit + 1, Column9: scope, Column10: includeNeighbors, + Column11: pubkeyPrefix, }) if err != nil { return api.Page[api.NodeSummary]{}, err diff --git a/db/nodes_test.go b/db/nodes_test.go index 03e0221..a0ae8d1 100644 --- a/db/nodes_test.go +++ b/db/nodes_test.go @@ -134,7 +134,7 @@ func TestListNodes_Pagination(t *testing.T) { Return(rows, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 0, 2, false) + page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", "", 0, 2, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,7 +167,7 @@ func TestListNodes_IATAsUnmarshal(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -201,7 +201,7 @@ func TestListNodes_RadioStringFormatting(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -392,7 +392,7 @@ func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, true) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, true) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -418,7 +418,7 @@ func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 375d790..e06ad7f 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -612,6 +612,7 @@ WHERE AND ($6 = '' OR n.name ILIKE '%' || $6 || '%') AND ($7::timestamptz IS NULL OR n.last_seen < $7) AND ($9::text = '' OR ts.name = $9::text) + AND ($11::text = '' OR encode(n.public_key, 'hex') ILIKE $11 || '%') GROUP BY n.id, ts.name ORDER BY n.last_seen DESC LIMIT $8; diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index d6a48b8..608051b 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -2065,6 +2065,7 @@ WHERE AND ($6 = '' OR n.name ILIKE '%' || $6 || '%') AND ($7::timestamptz IS NULL OR n.last_seen < $7) AND ($9::text = '' OR ts.name = $9::text) + AND ($11::text = '' OR encode(n.public_key, 'hex') ILIKE $11 || '%') GROUP BY n.id, ts.name ORDER BY n.last_seen DESC LIMIT $8 @@ -2081,6 +2082,7 @@ type ListNodesParams struct { Limit int32 `json:"limit"` Column9 string `json:"column_9"` Column10 bool `json:"column_10"` + Column11 string `json:"column_11"` } type ListNodesRow struct { @@ -2114,6 +2116,7 @@ func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNod arg.Limit, arg.Column9, arg.Column10, + arg.Column11, ) if err != nil { return nil, err diff --git a/docs/docs.go b/docs/docs.go index 04a598d..7579a06 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -509,6 +509,12 @@ const docTemplate = `{ "name": "pubkey", "in": "query" }, + { + "type": "string", + "description": "Partial public key match: hex prefix, case-insensitive", + "name": "pubkeyPrefix", + "in": "query" + }, { "type": "boolean", "description": "Filter by multibyte path support (true/false); omit for no filter", diff --git a/docs/swagger.json b/docs/swagger.json index ce73a33..e1200f4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -507,6 +507,12 @@ "name": "pubkey", "in": "query" }, + { + "type": "string", + "description": "Partial public key match: hex prefix, case-insensitive", + "name": "pubkeyPrefix", + "in": "query" + }, { "type": "boolean", "description": "Filter by multibyte path support (true/false); omit for no filter", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4086cda..5b883b1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1353,6 +1353,10 @@ paths: in: query name: pubkey type: string + - description: 'Partial public key match: hex prefix, case-insensitive' + in: query + name: pubkeyPrefix + type: string - description: Filter by multibyte path support (true/false); omit for no filter in: query name: supportsMultibytePaths diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 7e46a78..65f4329 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -44,6 +44,7 @@ func NodesRouter(reader api.Reader) http.Handler { // @Param name query string false "Partial case-insensitive name match" // @Param scope query string false "Filter by transport scope name e.g. %23bc (URL-encoded #bc)" // @Param pubkey query string false "Exact public key match (hex)" +// @Param pubkeyPrefix query string false "Partial public key match: hex prefix, case-insensitive" // @Param supportsMultibytePaths query bool false "Filter by multibyte path support (true/false); omit for no filter" // @Param supportsMultibyteTraces query bool false "Filter by multibyte trace support (true/false); omit for no filter" // @Param neighbors query bool false "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none" @@ -93,6 +94,11 @@ func listNodes(reader api.Reader) http.HandlerFunc { } pubkey = b } + pubkeyPrefix := strings.ToLower(r.URL.Query().Get("pubkeyPrefix")) + if pubkeyPrefix != "" && !isHexString(pubkeyPrefix) { + respondError(w, http.StatusBadRequest, "pubkeyPrefix must be a hex string") + return + } iatas := parseIATAs(r) if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" { regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader) @@ -136,7 +142,7 @@ func listNodes(reader api.Reader) http.HandlerFunc { includeNeighbors = b } } - nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) + nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, pubkeyPrefix, name, scope, cursor, limit, includeNeighbors) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return @@ -242,3 +248,16 @@ func listNodeNeighbors(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, neighbors) } } + +// isHexString reports whether s consists only of hex digits (any case). Used to validate +// pubkeyPrefix, which is matched as text (not decoded to bytes, since a prefix can be an odd +// number of nibbles) directly against a SQL ILIKE pattern -- restricting the charset to hex +// digits keeps that safe from ILIKE wildcard injection (%, _) as a side effect. +func isHexString(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} diff --git a/internal/api/handlers/nodes_test.go b/internal/api/handlers/nodes_test.go index a7f9b4b..8ab84ba 100644 --- a/internal/api/handlers/nodes_test.go +++ b/internal/api/handlers/nodes_test.go @@ -129,7 +129,7 @@ func TestListNodes_OK(t *testing.T) { nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") r := chi.NewRouter() r.Get("/nodes", listNodes(stubReader{ - listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID}}}, nil }, })) @@ -147,7 +147,7 @@ func TestListNodes_NeighborsParam_PassedThrough(t *testing.T) { var gotIncludeNeighbors bool r := chi.NewRouter() r.Get("/nodes", listNodes(stubReader{ - listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { gotIncludeNeighbors = includeNeighbors return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID, NeighborIDs: []uuid.UUID{neighborID}}}}, nil }, @@ -181,7 +181,7 @@ func TestListNodes_NeighborsParam_BareFlagMeansTrue(t *testing.T) { var gotIncludeNeighbors bool r := chi.NewRouter() r.Get("/nodes", listNodes(stubReader{ - listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { gotIncludeNeighbors = includeNeighbors return api.Page[api.NodeSummary]{}, nil }, @@ -197,6 +197,51 @@ func TestListNodes_NeighborsParam_BareFlagMeansTrue(t *testing.T) { } } +func TestListNodes_PubkeyPrefixParam_PassedThrough(t *testing.T) { + var gotPubkeyPrefix string + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{ + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, pubkeyPrefix, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { + gotPubkeyPrefix = pubkeyPrefix + return api.Page[api.NodeSummary]{}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=AB12", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if gotPubkeyPrefix != "ab12" { + t.Errorf("expected pubkeyPrefix to be lowercased and passed through, got %q", gotPubkeyPrefix) + } +} + +func TestListNodes_PubkeyPrefixParam_InvalidHex(t *testing.T) { + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=zz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestListNodes_PubkeyPrefixParam_RejectsWildcards(t *testing.T) { + // isHexString should reject ILIKE wildcard characters, since pubkeyPrefix is matched as + // raw text rather than decoded to bytes -- % or _ getting through would let a caller + // widen the match pattern beyond a literal prefix. + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=ab%25", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + func TestGetNode_OK(t *testing.T) { nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") r := chi.NewRouter() diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index 2291eca..6fc744f 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -31,7 +31,7 @@ type stubReader struct { getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error) listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) - listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) + listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) getNode func(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) getNodeNeighbors func(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) listNodeObservations func(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) @@ -171,9 +171,9 @@ func (s stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUI return api.Page[api.AdvertObservation]{}, nil } -func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { +func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { if s.listNodes != nil { - return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) + return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, pubkeyPrefix, name, scope, cursor, limit, includeNeighbors) } return api.Page[api.NodeSummary]{}, nil } diff --git a/internal/api/reader.go b/internal/api/reader.go index 3e0050d..0a1e059 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -100,7 +100,7 @@ type Reader interface { // When includeNeighbors is true, each NodeSummary's NeighborIDs field is // populated with the distinct set of neighbor node IDs (across all // IATAs); otherwise it's left nil to avoid the extra aggregation. - ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (Page[NodeSummary], error) + ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (Page[NodeSummary], error) // GetNode returns full detail for a single node by UUID. // Returns nil, pgx.ErrNoRows if the node is not found. diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index eb8d902..72d6fcf 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -136,7 +136,7 @@ func (s *stubReader) ListMessagesAfterID(_ context.Context, _ int64, _ []string, return nil, nil } -func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { +func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { return api.Page[api.NodeSummary]{}, nil } diff --git a/internal/cache/reader.go b/internal/cache/reader.go index 5aa984d..277a6a0 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -344,8 +344,8 @@ func (cr *CachedReader) ListMessagesAfterID(ctx context.Context, afterID int64, } // ListNodes implements [api.Reader]. -func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { - return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) +func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, pubkeyPrefix, name, scope, cursor, limit, includeNeighbors) } // ListNodeObservations implements [api.Reader].