Files

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
}