package main import ( "context" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "sync" "time" ) const addressCacheRadius = 25.0 // Metres from the coordinates used for the lookup. const addressCacheTTL = 5 * time.Minute type nearbyAddress struct { Label string `json:"label"` DistanceMeters float64 `json:"distance_meters"` Attribution string `json:"attribution"` } type addressResult struct { Status string `json:"status"` Address *nearbyAddress `json:"address,omitempty"` } type cachedAddress struct { queryLat, queryLon, lat, lon float64 label string at time.Time } type geocoder struct { endpoint, key string client *http.Client mu sync.RWMutex cache []cachedAddress lastAttempt time.Time failed bool } func newGeocoder(endpoint, key string) (*geocoder, error) { if endpoint == "" { return nil, nil } u, err := url.Parse(endpoint) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil { return nil, fmt.Errorf("geocoder URL must be an HTTP(S) endpoint without embedded credentials") } if strings.EqualFold(u.Hostname(), "nominatim.openstreetmap.org") { return nil, fmt.Errorf("use a tracking-compatible provider or your own Nominatim instance") } return &geocoder{endpoint: endpoint, key: key, client: &http.Client{Timeout: 8 * time.Second}}, nil } // Straight-line distance between WGS84 coordinates, in metres. func distanceMeters(lat1, lon1, lat2, lon2 float64) float64 { const rad = math.Pi / 180 a := math.Pow(math.Sin((lat2-lat1)*rad/2), 2) + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Pow(math.Sin((lon2-lon1)*rad/2), 2) return 6371000 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a)))) } func (g *geocoder) cached(lat, lon float64, now time.Time) *cachedAddress { for i := len(g.cache) - 1; i >= 0; i-- { c := &g.cache[i] if now.Sub(c.at) < addressCacheTTL && distanceMeters(lat, lon, c.queryLat, c.queryLon) <= addressCacheRadius { return c } } return nil } func (g *geocoder) view(lat, lon float64, now time.Time) addressResult { if g == nil { return addressResult{Status: "disabled"} } g.mu.RLock() defer g.mu.RUnlock() if c := g.cached(lat, lon, now); c != nil { return addressResult{Status: "ready", Address: &nearbyAddress{Label: c.label, DistanceMeters: distanceMeters(lat, lon, c.lat, c.lon), Attribution: "© OpenStreetMap contributors"}} } if g.failed { return addressResult{Status: "unavailable"} } return addressResult{Status: "pending"} } func (g *geocoder) update(ctx context.Context, lat, lon float64, now time.Time) { g.mu.Lock() if g.cached(lat, lon, now) != nil || (!g.lastAttempt.IsZero() && now.Sub(g.lastAttempt) < 30*time.Second) { g.mu.Unlock() return } g.lastAttempt = now g.mu.Unlock() c, err := g.lookup(ctx, lat, lon) g.mu.Lock() defer g.mu.Unlock() g.failed = err != nil if err != nil { return } // Keep provider errors and credentials out of API responses and logs. c.at = now if len(g.cache) >= 128 { g.cache = g.cache[1:] } g.cache = append(g.cache, c) } func (g *geocoder) lookup(ctx context.Context, lat, lon float64) (cachedAddress, error) { u, _ := url.Parse(g.endpoint) q := u.Query() q.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64)) q.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64)) q.Set("format", "jsonv2") q.Set("zoom", "18") q.Set("addressdetails", "1") u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return cachedAddress{}, err } req.Header.Set("User-Agent", "Macker/1.0") if g.key != "" { req.Header.Set("Authorization", "Bearer "+g.key) } res, err := g.client.Do(req) if err != nil { return cachedAddress{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return cachedAddress{}, fmt.Errorf("geocoder status %d", res.StatusCode) } raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20+1)) if err != nil { return cachedAddress{}, err } if len(raw) > 1<<20 { return cachedAddress{}, fmt.Errorf("geocoder response too large") } var v struct { Lat string `json:"lat"` Lon string `json:"lon"` DisplayName string `json:"display_name"` } if err = json.Unmarshal(raw, &v); err != nil { return cachedAddress{}, err } alat, e1 := strconv.ParseFloat(v.Lat, 64) alon, e2 := strconv.ParseFloat(v.Lon, 64) if e1 != nil || e2 != nil || math.IsNaN(alat) || math.IsNaN(alon) || math.IsInf(alat, 0) || math.IsInf(alon, 0) || math.Abs(alat) > 90 || math.Abs(alon) > 180 || strings.TrimSpace(v.DisplayName) == "" { return cachedAddress{}, fmt.Errorf("no usable address") } return cachedAddress{queryLat: lat, queryLon: lon, lat: alat, lon: alon, label: v.DisplayName}, nil } func (t *tracker) runGeocoder(ctx context.Context) { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: t.mu.RLock() var current *location if t.latest != nil { copy := *t.latest current = © } t.mu.RUnlock() if current != nil && now.Sub(current.UpdatedAt) <= 30*time.Second { t.geocoder.update(ctx, current.Point.Lat, current.Point.Long, now) } } } }