mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 22:21:27 +00:00
feat: node prefix search
pubkeyPrefix takes any even length prefix string case-insensitive and returns all matches
This commit is contained in:
13 files changed
+102
-17
No files matched your search
+2
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -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].
|
||||
|
||||
Reference in new issue
Block a user