Files
beacon-server/internal/api/handlers/observer_directory.go
MrAlders0n 041d9c1f45 release: v2.0.2
Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger.

MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon.

Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
2026-10-05 18:35:41 -04:00

141 lines
5.1 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
// listObserverDirectory godoc
//
// @Summary List observers with windowed traffic counts
// @Description Defaults to the trailing seven days through request time. Counts combine completed whole-hour analytics with retained observations for other hours and exact boundary slices. Incomplete history still returns numeric counts and the requested sort; coverage describes available history. Repeat filters and the returned windowStart/windowEnd as since/until with the cursor on later pages. Results may move as data changes; refresh without bounds for a new rolling window.
// @Tags Observers
// @Produce json
// @Param iata query string false "Single IATA"
// @Param iatas query string false "Comma-separated IATAs"
// @Param regionId query int false "Region ID"
// @Param region query string false "Region slug"
// @Param type query string false "Observer type"
// @Param broker query string false "Broker membership"
// @Param status query string false "online or offline"
// @Param name query string false "Case-insensitive partial name"
// @Param scope query string false "Transport scope membership"
// @Param sort query string false "traffic (default) or name"
// @Param since query int64 false "Inclusive epoch ms, preserved exactly; default seven days before until; maximum window 31 days"
// @Param until query int64 false "Exclusive epoch ms, preserved exactly; default request time"
// @Param cursor query int64 false "nextCursor returned by preceding page"
// @Param limit query int false "Page size, default 50, maximum 200"
// @Success 200 {object} api.ObserverDirectory
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /observers/directory [get]
func listObserverDirectory(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
allowed := map[string]bool{"iata": true, "iatas": true, "regionId": true, "region": true, "type": true, "broker": true, "status": true, "name": true, "scope": true, "sort": true, "since": true, "until": true, "cursor": true, "limit": true}
for k, v := range values {
if !allowed[k] || len(v) != 1 {
respondError(w, 400, "unknown or repeated directory parameter")
return
}
}
q := api.ObserverDirectoryQuery{}
limit, err := parseLimit(r, 50)
if err != nil {
respondError(w, 400, err.Error())
return
}
q.Limit = limit
if values.Has("cursor") {
q.Cursor, err = strconv.ParseInt(values.Get("cursor"), 10, 32)
if err != nil || q.Cursor < 0 || q.Cursor > 2147483447 {
respondError(w, 400, "invalid directory cursor")
return
}
}
if q.Cursor > 0 && (!values.Has("since") || !values.Has("until")) {
respondError(w, 400, "continuation requires since and until from the first page")
return
}
q.Sort = values.Get("sort")
if q.Sort == "" {
q.Sort = "traffic"
}
if q.Sort != "traffic" && q.Sort != "name" {
respondError(w, 400, "sort must be traffic or name")
return
}
q.Type = values.Get("type")
q.Broker = values.Get("broker")
q.Status = values.Get("status")
q.Name = strings.ToLower(values.Get("name"))
q.Scope = values.Get("scope")
if q.Status != "" && q.Status != "online" && q.Status != "offline" {
respondError(w, 400, "status must be online or offline")
return
}
for _, v := range []string{q.Type, q.Broker, q.Name, q.Scope} {
if len(v) > 200 {
respondError(w, 400, "directory filters must be at most 200 bytes")
return
}
}
now := time.Now().UTC().Truncate(time.Millisecond)
until := now
if values.Has("until") {
n, e := strconv.ParseInt(values.Get("until"), 10, 64)
if e != nil || n <= 0 || n > now.UnixMilli() {
respondError(w, 400, "until must be a past epoch millisecond timestamp")
return
}
until = time.UnixMilli(n).UTC()
}
since := until.Add(-7 * 24 * time.Hour)
if values.Has("since") {
n, e := strconv.ParseInt(values.Get("since"), 10, 64)
if e != nil || n <= 0 {
respondError(w, 400, "since must be positive epoch milliseconds")
return
}
since = time.UnixMilli(n).UTC()
}
if !since.Before(until) || until.Sub(since) > 31*24*time.Hour {
respondError(w, 400, "directory window must be positive and at most 31 days")
return
}
q.Since = since.UnixMilli()
q.Until = until.UnixMilli()
q.IATAs = parseIATAs(r)
if values.Get("regionId") != "" || values.Get("region") != "" {
iatas, err := resolveRegionIATAs(r.Context(), values.Get("regionId"), values.Get("region"), reader)
if err != nil {
respondRegionError(w, err)
return
}
q.IATAs = append(q.IATAs, iatas...)
q.MatchNone = len(q.IATAs) == 0
}
if len(q.IATAs) > 200 {
respondError(w, 400, "too many IATA filters")
return
}
slices.Sort(q.IATAs)
q.IATAs = slices.Compact(q.IATAs)
page, err := reader.ListObserverDirectory(r.Context(), q)
w.Header().Set("Cache-Control", "no-store")
if err != nil {
respondError(w, 500, "failed to get observer directory")
return
}
respond(w, 200, page)
}
}