Files
beacon-server/internal/api/handlers/route_evidence.go
n30nex a9b8c60a97 feat: stage current preview on dev for dev1 evaluation
Port the qualified server preview at 84ac3c8781 onto
org dev c7209b7043. Preserve upstream changes and
branch policies; keep the independent private Collector service separate.

Preview-Version: 2.2.0-n30nex.1
Preview-Revision: 84ac3c8781
2026-10-04 16:31:48 -04:00

133 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.RouteEvidenceQuery, error) {
q := api.RouteEvidenceQuery{}
values := r.URL.Query()
for _, name := range []string{"pageCursor", "since", "until", "range", "limit", "hashSize", "pathBytes"} {
if len(values[name]) > 1 {
return q, api.ErrRouteEvidenceInput
}
}
if values.Has("cursor") {
return q, api.ErrRouteEvidenceInput
}
limit, err := parseLimit(r, 50)
if err != nil {
return q, err
}
q.Limit = limit
if values.Has("pageCursor") {
c, err := api.ParseRouteEvidenceCursor(values.Get("pageCursor"))
if err != nil || c.IATA != iata || c.PathKey != key {
return q, api.ErrRouteEvidenceInput
}
q.Cursor = c
q.Since = c.Since
q.Until = c.Until
q.HashSize, q.PathBytes = c.HashSize, c.PathBytes
} else {
q.Until = now.UTC().Truncate(time.Millisecond)
q.Since = q.Until.Add(-24 * time.Hour)
}
if values.Has("range") {
if values.Has("pageCursor") || values.Has("since") || values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
duration, err := time.ParseDuration(values.Get("range"))
if err != nil || duration < time.Millisecond || duration > api.MaxRouteEvidenceWindow || duration%time.Millisecond != 0 {
return q, api.ErrRouteEvidenceInput
}
q.Since = q.Until.Add(-duration)
}
if values.Has("since") || values.Has("until") {
if !values.Has("since") || !values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
since, e1 := strconv.ParseInt(values.Get("since"), 10, 64)
until, e2 := strconv.ParseInt(values.Get("until"), 10, 64)
if e1 != nil || e2 != nil || since < 0 || until < 0 || since > 253402300799999 || until > 253402300799999 {
return q, api.ErrRouteEvidenceInput
}
q.Since = time.UnixMilli(since).UTC()
q.Until = time.UnixMilli(until).UTC()
if q.Cursor != nil && (!q.Since.Equal(q.Cursor.Since) || !q.Until.Equal(q.Cursor.Until)) {
return q, api.ErrRouteEvidenceInput
}
}
if values.Has("hashSize") || values.Has("pathBytes") {
width, err := strconv.ParseInt(values.Get("hashSize"), 10, 16)
path := values.Get("pathBytes")
if err != nil || !api.ValidRouteEvidencePath(int16(width), path) || strconv.FormatInt(width, 10) != values.Get("hashSize") {
return q, api.ErrRouteEvidenceInput
}
if q.Cursor != nil && q.Cursor.HashSize != 0 && (q.Cursor.HashSize != int16(width) || q.Cursor.PathBytes != path) {
return q, api.ErrRouteEvidenceInput
}
q.HashSize, q.PathBytes = int16(width), path
}
if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now.Add(clockSkewTolerance)) {
return q, api.ErrRouteEvidenceInput
}
return q, nil
}
// getRouteEvidence godoc
//
// @Summary Get retained reports matching a full saved route prefix sequence
// @Description Matches one complete path representation in the saved IATA. Defaults to the latest processed prefixes; hashSize/pathBytes pin a prior response. Excludes TRACE and unclassified reports; widths are never combined. Pinned prefixes must match the saved node chain or its current metadata. Byte matches do not prove hop identities or delivery. The route counter can outlive raw evidence. New cursors pin path, route, window and full timestamp precision; legacy unpinned cursors remain supported.
// @Tags Routes
// @Produce json
// @Param iata path string true "Three-character IATA code"
// @Param pathKey path string true "Stable 32-hex pathKey from a known-route response"
// @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor"
// @Param since query int false "Window start epoch ms; provide with until (default last 24h)"
// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, end may be up to 5 minutes ahead of server time (clock skew tolerance)"
// @Param hashSize query int false "Exact hash width (1–3); provide with pathBytes to pin a response's representation" minimum(1) maximum(3)
// @Param pathBytes query string false "Complete lower-case hex path from the response; provide with hashSize"
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; route, window and path are pinned"
// @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200)
// @Success 200 {object} api.RouteEvidence
// @Failure 400 {object} handlers.APIError
// @Failure 404 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /routes/{iata}/{pathKey}/observations [get]
func getRouteEvidence(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iata, key := strings.ToUpper(chi.URLParam(r, "iata")), strings.ToLower(chi.URLParam(r, "pathKey"))
if !api.ValidRouteEvidenceKey(iata, key) {
respondError(w, 400, "invalid IATA or route pathKey")
return
}
query, err := routeEvidenceQuery(r, iata, key, time.Now())
if err != nil {
respondError(w, 400, "invalid route evidence window, path, limit or page cursor")
return
}
result, err := reader.GetRouteEvidence(r.Context(), iata, key, query)
switch {
case errors.Is(err, pgx.ErrNoRows), err == nil && result == nil:
respondError(w, 404, "saved route not found")
case errors.Is(err, api.ErrRouteEvidenceInput):
respondError(w, 400, "invalid route evidence request")
case err != nil:
respondError(w, 500, "internal server error")
default:
respond(w, 200, result)
}
}
}