mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-20 09:51:55 +00:00
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.
141 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|