mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 13:49:56 +00:00
274 lines
8.6 KiB
Go
274 lines
8.6 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package meshmapper
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
type zoneEntry struct {
|
|
url string
|
|
hasBoundary bool
|
|
group string // first group listing it, "" when none
|
|
name string // short display name, e.g. "Nanaimo"
|
|
lat, lon *float64 // nil when missing or out of range
|
|
}
|
|
|
|
type zoneGroup struct {
|
|
name string
|
|
members []string
|
|
}
|
|
|
|
type zoneList struct {
|
|
zones map[string]zoneEntry
|
|
groups map[string]zoneGroup
|
|
etag string
|
|
fetchedAt, nextAttempt time.Time
|
|
fetching bool
|
|
}
|
|
|
|
// ZoneList is one country's saved get_zones.php response and fetch state.
|
|
type ZoneList struct {
|
|
Country string
|
|
Payload json.RawMessage
|
|
ETag string
|
|
FetchedAt, AttemptedAt, NextAttempt time.Time
|
|
LastError string
|
|
}
|
|
|
|
type DirectoryStore interface {
|
|
ListZoneLists(ctx context.Context) ([]ZoneList, error)
|
|
SaveZoneList(ctx context.Context, l ZoneList) error
|
|
}
|
|
|
|
// Directory caches MeshMapper's per-country zone lists, shared by the zones and scopes imports.
|
|
type Directory struct {
|
|
mu sync.Mutex
|
|
store DirectoryStore
|
|
client *http.Client
|
|
listURL string
|
|
lists map[string]*zoneList
|
|
version int // bumped whenever a list is replaced
|
|
}
|
|
|
|
func NewDirectory(store DirectoryStore, apiKey string) *Directory {
|
|
return &Directory{store: store, listURL: ZonesURL, lists: map[string]*zoneList{}, client: newClient(apiKey)}
|
|
}
|
|
|
|
// Restore loads saved lists so a restart doesn't spend each country's daily request.
|
|
func (d *Directory) Restore(ctx context.Context) error {
|
|
saved, err := d.store.ListZoneLists(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("restore MeshMapper zone lists: %w", err)
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
for _, l := range saved {
|
|
list := &zoneList{nextAttempt: l.NextAttempt}
|
|
if zones, groups, err := decodeZones(l.Payload, l.Country); err == nil {
|
|
list.zones, list.groups, list.etag, list.fetchedAt = zones, groups, l.ETag, l.FetchedAt
|
|
d.version++
|
|
}
|
|
d.lists[l.Country] = list
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns the country's fresh zone list. fetched reports that this call
|
|
// spent the caller's one request; nil zones without fetched means back off,
|
|
// including while another task's request for the country is in flight.
|
|
func (d *Directory) List(ctx context.Context, country string, now time.Time) (zones map[string]zoneEntry, fetched bool, err error) {
|
|
d.mu.Lock()
|
|
list := d.lists[country]
|
|
if list != nil && list.zones != nil && now.Sub(list.fetchedAt) < zoneListFresh {
|
|
d.mu.Unlock()
|
|
return list.zones, false, nil
|
|
}
|
|
if list != nil && (list.fetching || now.Before(list.nextAttempt)) {
|
|
d.mu.Unlock()
|
|
return nil, false, nil
|
|
}
|
|
if list == nil {
|
|
list = &zoneList{}
|
|
d.lists[country] = list
|
|
}
|
|
// Every request counts against the country's 23.5h allowance, failed, abandoned or not.
|
|
list.fetching, list.nextAttempt = true, now.Add(zoneListFresh)
|
|
cached, etag := list.zones != nil, ""
|
|
if cached {
|
|
etag = list.etag
|
|
}
|
|
d.mu.Unlock()
|
|
defer func() {
|
|
d.mu.Lock()
|
|
list.fetching = false
|
|
d.mu.Unlock()
|
|
}()
|
|
return nil, true, d.fetch(ctx, list, country, cached, etag, now)
|
|
}
|
|
|
|
// fetch runs without d.mu so other tasks never wait out this request.
|
|
func (d *Directory) fetch(ctx context.Context, list *zoneList, country string, cached bool, etag string, now time.Time) error {
|
|
attempt := ZoneList{Country: country, AttemptedAt: now, NextAttempt: now.Add(zoneListFresh), LastError: "no response"}
|
|
if err := d.store.SaveZoneList(ctx, attempt); err != nil {
|
|
return fmt.Errorf("persist MeshMapper zone list %s: %w", country, err)
|
|
}
|
|
endpoint := d.listURL + "?country=" + url.QueryEscape(country)
|
|
status, body, header, err := get(ctx, d.client, "Beacon-MeshMapper-Zones/1", endpoint, etag, MaxZoneList)
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
problem := ""
|
|
var payload json.RawMessage
|
|
var retryAt time.Time
|
|
var zones map[string]zoneEntry
|
|
var groups map[string]zoneGroup
|
|
switch {
|
|
case err != nil:
|
|
problem = requestProblem(err)
|
|
case status == http.StatusOK:
|
|
var decodeErr error
|
|
if zones, groups, decodeErr = decodeZones(body, country); decodeErr != nil {
|
|
problem = "invalid response"
|
|
} else {
|
|
payload = body
|
|
}
|
|
case status == http.StatusNotModified:
|
|
if !cached {
|
|
problem = "304 without cached list"
|
|
}
|
|
default:
|
|
problem = statusProblem(status, header, now, &retryAt)
|
|
}
|
|
d.mu.Lock()
|
|
if payload != nil {
|
|
list.zones, list.groups, list.etag = zones, groups, header.Get("ETag")
|
|
d.version++
|
|
}
|
|
if retryAt.After(list.nextAttempt) {
|
|
list.nextAttempt = retryAt
|
|
}
|
|
saved := ZoneList{Country: country, Payload: payload, AttemptedAt: now, NextAttempt: list.nextAttempt, LastError: problem}
|
|
if problem == "" {
|
|
list.fetchedAt = now
|
|
saved.FetchedAt, saved.ETag = now, list.etag
|
|
}
|
|
nZones, nGroups := len(list.zones), len(list.groups)
|
|
d.mu.Unlock()
|
|
level := slog.LevelInfo
|
|
if problem != "" {
|
|
level = slog.LevelWarn
|
|
}
|
|
slog.Log(ctx, level, "MeshMapper zone list checked", "component", "meshmapper.zones", "country", country,
|
|
"zones", nZones, "groups", nGroups, "next_attempt", saved.NextAttempt, "last_error", problem)
|
|
if err := d.store.SaveZoneList(ctx, saved); err != nil {
|
|
return fmt.Errorf("persist MeshMapper zone list %s: %w", country, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// snapshot merges every loaded list's zones and groups by code. complete is false while
|
|
// a country has no list, so a cold start or failed list can't look like groups disappearing.
|
|
func (d *Directory) snapshot(countries []string) (merged map[string]zoneGroup, zones map[string]zoneEntry, version int, complete bool) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
merged, zones, complete = map[string]zoneGroup{}, map[string]zoneEntry{}, true
|
|
for _, country := range countries {
|
|
list := d.lists[country]
|
|
if list == nil || list.zones == nil {
|
|
complete = false
|
|
continue
|
|
}
|
|
for code, z := range list.zones {
|
|
zones[code] = z
|
|
}
|
|
for code, g := range list.groups {
|
|
if have, ok := merged[code]; ok {
|
|
g.members = append(slices.Clone(have.members), g.members...)
|
|
slices.Sort(g.members)
|
|
g.members = slices.Compact(g.members)
|
|
g.name = have.name
|
|
}
|
|
merged[code] = g
|
|
}
|
|
}
|
|
return merged, zones, d.version, complete
|
|
}
|
|
|
|
var (
|
|
groupCode = regexp.MustCompile(`^[A-Z0-9]{2,16}$`)
|
|
memberIATA = regexp.MustCompile(`^[A-Z]{3}$`)
|
|
)
|
|
|
|
func decodeZones(body []byte, country string) (map[string]zoneEntry, map[string]zoneGroup, error) {
|
|
var document struct {
|
|
Country string `json:"country"`
|
|
Zones *[]struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
ShortName string `json:"short_name"`
|
|
Lat *float64 `json:"lat"`
|
|
Lon *float64 `json:"lon"`
|
|
URL string `json:"url"`
|
|
HasBoundary bool `json:"has_boundary"`
|
|
Group *string `json:"group"`
|
|
} `json:"zones"`
|
|
Groups []struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Members []string `json:"members"`
|
|
} `json:"groups"`
|
|
}
|
|
if err := json.Unmarshal(body, &document); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !strings.EqualFold(document.Country, country) || document.Zones == nil {
|
|
return nil, nil, fmt.Errorf("invalid zone list")
|
|
}
|
|
zones := make(map[string]zoneEntry, len(*document.Zones))
|
|
for _, zone := range *document.Zones {
|
|
entry := zoneEntry{url: zone.URL, hasBoundary: zone.HasBoundary, name: cmp.Or(zone.ShortName, zone.Name)}
|
|
if entry.name = strings.TrimSpace(entry.name); len(entry.name) > 128 || strings.ContainsFunc(entry.name, unicode.IsControl) {
|
|
entry.name = ""
|
|
}
|
|
if zone.Group != nil && groupCode.MatchString(strings.ToUpper(*zone.Group)) {
|
|
entry.group = strings.ToUpper(*zone.Group)
|
|
}
|
|
if zone.Lat != nil && zone.Lon != nil && *zone.Lat >= -90 && *zone.Lat <= 90 && *zone.Lon >= -180 && *zone.Lon <= 180 {
|
|
entry.lat, entry.lon = zone.Lat, zone.Lon
|
|
}
|
|
zones[strings.ToUpper(zone.Code)] = entry
|
|
}
|
|
groups := map[string]zoneGroup{}
|
|
for _, g := range document.Groups {
|
|
code, name := strings.ToUpper(g.Code), strings.TrimSpace(g.Name)
|
|
if !groupCode.MatchString(code) || name == "" || len(name) > 128 || strings.ContainsFunc(name, unicode.IsControl) {
|
|
continue // a bad group never blocks the zone list
|
|
}
|
|
var members []string
|
|
for _, m := range g.Members {
|
|
if m = strings.ToUpper(m); memberIATA.MatchString(m) {
|
|
members = append(members, m)
|
|
}
|
|
}
|
|
slices.Sort(members)
|
|
if members = slices.Compact(members); len(members) > 0 {
|
|
groups[code] = zoneGroup{name: name, members: members}
|
|
}
|
|
}
|
|
return zones, groups, nil
|
|
}
|