mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-02 09:03:45 +00:00
pubkeyPrefix takes any even length prefix string case-insensitive and returns all matches
264 lines
9.2 KiB
Go
264 lines
9.2 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// NodesRouter mounts all /nodes routes onto a subrouter.
|
|
//
|
|
// GET /nodes → listNodes
|
|
// GET /nodes/{nodeId} → getNode
|
|
// GET /nodes/{nodeId}/observations → listNodeObservations
|
|
func NodesRouter(reader api.Reader) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/", listNodes(reader))
|
|
r.Route("/{nodeId}", func(r chi.Router) {
|
|
r.Get("/", getNode(reader))
|
|
r.Get("/observations", listNodeObservations(reader))
|
|
r.Get("/neighbors", listNodeNeighbors(reader))
|
|
})
|
|
return r
|
|
}
|
|
|
|
// listNodes godoc
|
|
//
|
|
// @Summary List nodes
|
|
// @Tags Nodes
|
|
// @Produce json
|
|
// @Param type query int false "Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)"
|
|
// @Param typeName query string false "Node type name (companion, repeater, room_server, sensor)"
|
|
// @Param iata query string false "Filter by single IATA code (case-insensitive)"
|
|
// @Param iatas query string false "Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @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"
|
|
// @Param cursor query int false "last_seen epoch ms of last item for pagination"
|
|
// @Param limit query int false "Max results (default 50)"
|
|
// @Success 200 {object} api.Page[api.NodeSummary]
|
|
// @Failure 400 {object} handlers.APIError
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /nodes [get]
|
|
func listNodes(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var nodeType int16
|
|
if typeParam := r.URL.Query().Get("type"); typeParam != "" {
|
|
t, err := strconv.ParseInt(typeParam, 10, 16)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "type must be an integer")
|
|
return
|
|
}
|
|
nodeType = int16(t)
|
|
} else if typeName := r.URL.Query().Get("typeName"); typeName != "" {
|
|
nodeType = api.NodeTypeFromString(typeName)
|
|
}
|
|
var limit int32 = 50
|
|
if limitParam := r.URL.Query().Get("limit"); limitParam != "" {
|
|
l, err := strconv.ParseInt(limitParam, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
var cursor int64
|
|
if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" {
|
|
c, err := strconv.ParseInt(cursorParam, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "cursor must be an integer")
|
|
return
|
|
}
|
|
cursor = c
|
|
}
|
|
var pubkey []byte
|
|
if pubkeyParam := strings.ToLower(r.URL.Query().Get("pubkey")); pubkeyParam != "" {
|
|
b, err := hex.DecodeString(pubkeyParam)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "pubkey must be a valid hex string")
|
|
return
|
|
}
|
|
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)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
name := r.URL.Query().Get("name")
|
|
scope := r.URL.Query().Get("scope")
|
|
var supportsMultibytePaths *bool
|
|
if v := r.URL.Query().Get("supportsMultibytePaths"); v != "" {
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid supportsMultibytePaths value")
|
|
return
|
|
}
|
|
supportsMultibytePaths = &b
|
|
}
|
|
var supportsMultibyteTraces *bool
|
|
if v := r.URL.Query().Get("supportsMultibyteTraces"); v != "" {
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid supportsMultibyteTraces value")
|
|
return
|
|
}
|
|
supportsMultibyteTraces = &b
|
|
}
|
|
var includeNeighbors bool
|
|
if vals, ok := r.URL.Query()["neighbors"]; ok {
|
|
v := vals[0] // bare `?neighbors` (no `=`) parses as ""
|
|
if v == "" {
|
|
includeNeighbors = true
|
|
} else {
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid neighbors value")
|
|
return
|
|
}
|
|
includeNeighbors = b
|
|
}
|
|
}
|
|
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
|
|
}
|
|
respond(w, http.StatusOK, nodes)
|
|
}
|
|
}
|
|
|
|
// getNode godoc
|
|
//
|
|
// @Summary Get node detail
|
|
// @Tags Nodes
|
|
// @Produce json
|
|
// @Param nodeId path string true "Node UUID"
|
|
// @Success 200 {object} api.Node
|
|
// @Failure 400 {object} handlers.APIError
|
|
// @Failure 404 {object} handlers.APIError
|
|
// @Router /nodes/{nodeId} [get]
|
|
func getNode(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid node ID")
|
|
return
|
|
}
|
|
node, err := reader.GetNode(r.Context(), nodeID)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "node not found")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, node)
|
|
}
|
|
}
|
|
|
|
// listNodeObservations godoc
|
|
//
|
|
// @Summary List packet observations originating from a node
|
|
// @Tags Nodes
|
|
// @Produce json
|
|
// @Param nodeId path string true "Node UUID"
|
|
// @Param cursor query int false "Observation ID of last item for pagination"
|
|
// @Param limit query int false "Max results (default 50)"
|
|
// @Success 200 {object} api.Page[api.PacketObservationSummary]
|
|
// @Failure 400 {object} handlers.APIError
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /nodes/{nodeId}/observations [get]
|
|
func listNodeObservations(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid node ID")
|
|
return
|
|
}
|
|
var cursor int64
|
|
if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" {
|
|
c, err := strconv.ParseInt(cursorParam, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "cursor must be an integer")
|
|
return
|
|
}
|
|
cursor = c
|
|
}
|
|
var limit int32 = 50
|
|
if limitParam := r.URL.Query().Get("limit"); limitParam != "" {
|
|
l, err := strconv.ParseInt(limitParam, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
observations, err := reader.ListNodeObservations(r.Context(), nodeID, cursor, limit)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, observations)
|
|
}
|
|
}
|
|
|
|
// listNodeNeighbors godoc
|
|
//
|
|
// @Summary List neighbors for a node
|
|
// @Tags Nodes
|
|
// @Produce json
|
|
// @Param nodeId path string true "Node UUID"
|
|
// @Success 200 {object} []api.NodeNeighbor
|
|
// @Failure 400 {object} handlers.APIError
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /nodes/{nodeId}/neighbors [get]
|
|
func listNodeNeighbors(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid node ID")
|
|
return
|
|
}
|
|
neighbors, err := reader.GetNodeNeighbors(r.Context(), nodeID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
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
|
|
}
|