feat: node prefix search

pubkeyPrefix takes any even length prefix string case-insensitive
and returns all matches
This commit is contained in:
Enot (ded) Skelly committed 2026-07-23 10:14:47 -07:00
1 parent d8c622316d
commit 0ca28cc6a8
13 files changed
+102 -17

No files matched your search

+2 -1
View File
@@ -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
+5 -5
View File
@@ -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)
}
+1
View File
@@ -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;
+3
View File
@@ -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
+6
View File
@@ -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",
+6
View File
@@ -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",
+4
View File
@@ -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
+20 -1
View File
@@ -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
}
+48 -3
View File
@@ -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()
+3 -3
View File
@@ -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
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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].