diff --git a/cmd/server/config.go b/cmd/server/config.go index f21ef207..29c94b49 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -404,3 +405,59 @@ func (c *Config) IsBlacklisted(pubkey string) bool { } return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))] } + +// SaveGeoFilter writes the geo_filter section back to config.json on disk. +// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is. +func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error { + var configPath string + for _, p := range []string{ + filepath.Join(configDir, "config.json"), + filepath.Join(configDir, "data", "config.json"), + } { + if _, err := os.Stat(p); err == nil { + configPath = p + break + } + } + if configPath == "" { + return fmt.Errorf("config.json not found in %s", configDir) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + // Parse as a raw map so non-struct fields (_comment, etc.) are preserved. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse config: %w", err) + } + + if gf == nil || len(gf.Polygon) == 0 { + delete(raw, "geo_filter") + } else { + // Round-trip through JSON to get a plain interface{} value. + b, _ := json.Marshal(gf) + var v interface{} + _ = json.Unmarshal(b, &v) + raw["geo_filter"] = v + } + + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + out = append(out, '\n') + + // Atomic write: temp file + rename. + tmp := configPath + ".tmp" + if err := os.WriteFile(tmp, out, 0644); err != nil { + return fmt.Errorf("write config: %w", err) + } + if err := os.Rename(tmp, configPath); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename config: %w", err) + } + return nil +} diff --git a/cmd/server/db.go b/cmd/server/db.go index aeb09769..82ae2082 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2344,3 +2344,61 @@ func (db *DB) GetSignatureDropCount() int64 { } return count } + +// NodeForGeoPrune holds the minimal fields needed for geo-filter pruning. +type NodeForGeoPrune struct { + PubKey string + Name string + Lat *float64 + Lon *float64 +} + +// GetNodesForGeoPrune returns all nodes with their coordinates for geo-filter evaluation. +func (db *DB) GetNodesForGeoPrune() ([]NodeForGeoPrune, error) { + rows, err := db.conn.Query("SELECT public_key, name, lat, lon FROM nodes ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var nodes []NodeForGeoPrune + for rows.Next() { + var pk string + var name sql.NullString + var lat, lon sql.NullFloat64 + if err := rows.Scan(&pk, &name, &lat, &lon); err != nil { + continue + } + n := NodeForGeoPrune{PubKey: pk, Name: name.String} + if lat.Valid { + v := lat.Float64 + n.Lat = &v + } + if lon.Valid { + v := lon.Float64 + n.Lon = &v + } + nodes = append(nodes, n) + } + return nodes, rows.Err() +} + +// DeleteNodesByPubkeys deletes nodes by their public keys and returns the count deleted. +// Only the nodes table is affected — references in transmissions or other tables are +// not cascaded (no FK constraints exist today; revisit if schema adds them). +func (db *DB) DeleteNodesByPubkeys(pubkeys []string) (int64, error) { + if len(pubkeys) == 0 { + return 0, nil + } + placeholders := strings.Repeat("?,", len(pubkeys)) + placeholders = placeholders[:len(placeholders)-1] + args := make([]interface{}, len(pubkeys)) + for i, pk := range pubkeys { + args[i] = pk + } + result, err := db.conn.Exec("DELETE FROM nodes WHERE public_key IN ("+placeholders+")", args...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 31fbcd4f..855212fa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -238,6 +238,7 @@ func main() { // HTTP server srv := NewServer(database, cfg, hub) + srv.configDir = configDir srv.store = store router := mux.NewRouter() srv.RegisterRoutes(router) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index aa7c2689..6e027ad9 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log" + "math" "net/http" "regexp" "runtime" @@ -25,12 +26,16 @@ type Server struct { cfg *Config hub *Hub store *PacketStore // in-memory packet store (nil = fallback to DB) + configDir string // directory containing config.json (for write-back) startedAt time.Time perfStats *PerfStats version string commit string buildTime string + // Guards s.cfg.GeoFilter — read by ingest/handler goroutines, written by PUT handler + cfgMu sync.RWMutex + // Cached runtime.MemStats to avoid stop-the-world pauses on every health check memStatsMu sync.Mutex memStatsCache runtime.MemStats @@ -59,6 +64,18 @@ type PerfStats struct { StartedAt time.Time } +func (s *Server) getGeoFilter() *GeoFilterConfig { + s.cfgMu.RLock() + defer s.cfgMu.RUnlock() + return s.cfg.GeoFilter +} + +func (s *Server) setGeoFilter(gf *GeoFilterConfig) { + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + s.cfg.GeoFilter = gf +} + type EndpointPerf struct { Count int TotalMs float64 @@ -117,6 +134,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET") r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET") r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET") + r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT") // System endpoints r.HandleFunc("/api/health", s.handleHealth).Methods("GET") @@ -124,6 +142,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/perf", s.handlePerf).Methods("GET") r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST") r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST") + r.Handle("/api/admin/prune-geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST") r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET") r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET") @@ -430,12 +449,67 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) { - gf := s.cfg.GeoFilter + gf := s.getGeoFilter() + // writeEnabled leaks whether the server has a strong API key to unauthenticated + // callers. Risk accepted: the information (key is/isn't configured) is low-sensitivity + // and the GET endpoint is intentionally public for read-only clients. + writeEnabled := s.cfg != nil && s.cfg.APIKey != "" && !IsWeakAPIKey(s.cfg.APIKey) if gf == nil || len(gf.Polygon) == 0 { - writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0}) + writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0, "writeEnabled": writeEnabled}) + return + } + writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm, "writeEnabled": writeEnabled}) +} + +func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap + + var body struct { + Polygon [][2]float64 `json:"polygon"` + BufferKm float64 `json:"bufferKm"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + // Allow clearing (empty/null polygon) or a valid polygon with ≥ 3 points. + if len(body.Polygon) > 0 && len(body.Polygon) < 3 { + writeError(w, http.StatusBadRequest, "polygon must have at least 3 points") return } - writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm}) + if len(body.Polygon) > 1000 { + writeError(w, http.StatusBadRequest, "polygon must have at most 1000 points") + return + } + for _, pt := range body.Polygon { + if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) || math.IsInf(pt[0], 0) || math.IsInf(pt[1], 0) || + pt[0] < -90 || pt[0] > 90 || pt[1] < -180 || pt[1] > 180 { + writeError(w, http.StatusBadRequest, "polygon point out of range: lat must be in [-90,90], lon in [-180,180]") + return + } + } + + var gf *GeoFilterConfig + if len(body.Polygon) >= 3 { + gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm} + } + + if s.configDir != "" { + if err := SaveGeoFilter(s.configDir, gf); err != nil { + log.Printf("[geofilter] save failed: %v", err) + writeError(w, http.StatusInternalServerError, "failed to save config") + return + } + } + + s.setGeoFilter(gf) + + if gf != nil { + writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm}) + } else { + writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0}) + } } // --- System Handlers --- @@ -1094,10 +1168,10 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { } } } - if s.cfg.GeoFilter != nil { + if gf := s.getGeoFilter(); gf != nil { filtered := nodes[:0] for _, node := range nodes { - if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) { + if NodePassesGeoFilter(node["lat"], node["lon"], gf) { filtered = append(filtered, node) } } @@ -2577,6 +2651,91 @@ func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) { writeJSON(w, results) } +// handlePruneGeoFilter identifies (dry_run=true, default) or deletes (confirm=true) +// nodes whose GPS coordinates fall outside the currently configured geo_filter. +// Nodes with no GPS fix are always kept. Requires geo_filter to be configured. +// Confirm requires the pubkeys from the preview in the request body to prevent +// TOCTOU races: only nodes in the passed list AND still outside the filter are deleted. +func (s *Server) handlePruneGeoFilter(w http.ResponseWriter, r *http.Request) { + if s.cfg.GeoFilter == nil || len(s.cfg.GeoFilter.Polygon) < 3 { + writeError(w, http.StatusBadRequest, "no geo_filter configured") + return + } + + nodes, err := s.db.GetNodesForGeoPrune() + if err != nil { + writeError(w, http.StatusInternalServerError, "db error") + return + } + + type nodeResult struct { + PubKey string `json:"pubkey"` + Name string `json:"name"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + } + + var outside []nodeResult + for _, n := range nodes { + if n.Lat == nil || n.Lon == nil { + continue // no GPS — always keep + } + if !NodePassesGeoFilter(*n.Lat, *n.Lon, s.cfg.GeoFilter) { + outside = append(outside, nodeResult{PubKey: n.PubKey, Name: n.Name, Lat: n.Lat, Lon: n.Lon}) + } + } + + if r.URL.Query().Get("confirm") != "true" { + // Dry run — return preview without deleting + writeJSON(w, map[string]interface{}{ + "dryRun": true, + "count": len(outside), + "nodes": outside, + }) + return + } + + // Confirmed delete — require pubkeys from the preview to prevent TOCTOU: + // only nodes that were shown in preview AND are still outside the filter are deleted. + var body struct { + Pubkeys []string `json:"pubkeys"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Pubkeys) == 0 { + writeError(w, http.StatusBadRequest, "confirm requires pubkeys from preview in request body") + return + } + allowed := make(map[string]bool, len(body.Pubkeys)) + for _, pk := range body.Pubkeys { + allowed[pk] = true + } + + var toDelete []nodeResult + for _, n := range outside { + if allowed[n.PubKey] { + toDelete = append(toDelete, n) + } + } + + pubkeys := make([]string, len(toDelete)) + for i, n := range toDelete { + pubkeys[i] = n.PubKey + } + deleted, err := s.db.DeleteNodesByPubkeys(pubkeys) + if err != nil { + writeError(w, http.StatusInternalServerError, "delete failed") + return + } + for _, n := range toDelete { + log.Printf("[geo-prune] deleted node %q (%s)", n.Name, n.PubKey) + } + log.Printf("[geo-prune] deleted %d nodes outside geo filter", deleted) + writeJSON(w, map[string]interface{}{ + "dryRun": false, + "deleted": deleted, + "nodes": toDelete, + }) +} + // constantTimeEqual compares two strings in constant time to prevent timing attacks. func constantTimeEqual(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 4ac15f54..8d0d1a49 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" "testing" @@ -2839,6 +2841,34 @@ func TestConfigGeoFilterEndpoint(t *testing.T) { if body["bufferKm"] == nil { t.Error("expected bufferKm in response") } + if _, ok := body["writeEnabled"]; !ok { + t.Error("expected writeEnabled field in response") + } + // No apiKey configured → writeEnabled should be false + if body["writeEnabled"] != false { + t.Errorf("expected writeEnabled=false when no apiKey, got %v", body["writeEnabled"]) + } + }) + + t.Run("writeEnabled true when strong apiKey configured", func(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{Port: 3000, APIKey: "a-strong-api-key-1234"} + hub := NewHub() + srv := NewServer(db, cfg, hub) + srv.store = NewPacketStore(db, nil) + srv.store.Load() + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/geo-filter", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["writeEnabled"] != true { + t.Errorf("expected writeEnabled=true when strong apiKey configured, got %v", body["writeEnabled"]) + } }) } @@ -3972,3 +4002,387 @@ func TestPacketDetailPrefersStoreOverDB(t *testing.T) { t.Errorf("expected observation_count=2 (from store), got %v", body["observation_count"]) } } + +// --- geo-filter write-back tests --- + +func setupGeoFilterServer(t *testing.T, apiKey string) (*Server, *mux.Router, string) { + t.Helper() + dir := t.TempDir() + cfgJSON := `{"port":3000,"apiKey":"` + apiKey + `"}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfgJSON), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000, APIKey: apiKey} + hub := NewHub() + srv := NewServer(db, cfg, hub) + srv.configDir = dir + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + return srv, router, dir +} + +func TestPutConfigGeoFilter(t *testing.T) { + const apiKey = "a-strong-api-key-for-testing" + + t.Run("saves valid polygon and updates in-memory config", func(t *testing.T) { + srv, router, dir := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0],[50.5,4.0]],"bufferKm":15}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // In-memory config updated + if srv.cfg.GeoFilter == nil { + t.Fatal("expected in-memory GeoFilter to be set") + } + if len(srv.cfg.GeoFilter.Polygon) != 4 { + t.Errorf("expected 4 polygon points, got %d", len(srv.cfg.GeoFilter.Polygon)) + } + if srv.cfg.GeoFilter.BufferKm != 15 { + t.Errorf("expected bufferKm=15, got %v", srv.cfg.GeoFilter.BufferKm) + } + + // config.json updated on disk + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if !bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter key in saved config.json") + } + }) + + t.Run("clears filter when polygon is empty", func(t *testing.T) { + srv, router, dir := setupGeoFilterServer(t, apiKey) + // Pre-set a filter so we can clear it + srv.setGeoFilter(&GeoFilterConfig{Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, BufferKm: 10}) + + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(`{"polygon":null}`)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + if srv.cfg.GeoFilter != nil { + t.Error("expected in-memory GeoFilter to be cleared") + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter to be removed from config.json") + } + }) + + t.Run("rejects polygon with fewer than 3 points", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0]],"bufferKm":0}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + + t.Run("rejects out-of-range coordinates", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[91.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for out-of-range lat, got %d", w.Code) + } + }) + + t.Run("rejects polygon exceeding 1000 points", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + pts := make([][2]float64, 1001) + for i := range pts { + pts[i] = [2]float64{51.0 + float64(i)*0.0001, 4.0} + } + b, _ := json.Marshal(map[string]interface{}{"polygon": pts, "bufferKm": 0}) + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(string(b))) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for oversized polygon, got %d", w.Code) + } + }) + + t.Run("rejects missing API key", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + }) +} + +func TestSaveGeoFilter(t *testing.T) { + t.Run("saves and reads back", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port":3000}`), 0644); err != nil { + t.Fatal(err) + } + gf := &GeoFilterConfig{ + Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, + BufferKm: 20, + } + if err := SaveGeoFilter(dir, gf); err != nil { + t.Fatalf("SaveGeoFilter: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if !bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter in saved config") + } + if !bytes.Contains(data, []byte(`"bufferKm"`)) { + t.Error("expected bufferKm in saved config") + } + }) + + t.Run("removes geo_filter key when gf is nil", func(t *testing.T) { + dir := t.TempDir() + initial := `{"port":3000,"geo_filter":{"polygon":[[1,2],[3,4],[5,6]],"bufferKm":5}}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(initial), 0644); err != nil { + t.Fatal(err) + } + if err := SaveGeoFilter(dir, nil); err != nil { + t.Fatalf("SaveGeoFilter: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter to be removed") + } + }) + + t.Run("returns error when config.json not found", func(t *testing.T) { + dir := t.TempDir() + err := SaveGeoFilter(dir, nil) + if err == nil { + t.Error("expected error when config.json not found") + } + }) +} + +// --- prune-geo-filter endpoint tests --- + +func setupPruneGeoFilterServer(t *testing.T, apiKey string, gf *GeoFilterConfig) (*Server, *mux.Router) { + t.Helper() + db := setupTestDB(t) + seedTestData(t, db) + // Add a node clearly outside the geo filter (high lat/lon in Europe) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES ('aaaa111122223333', 'OutsideNode', 'repeater', 51.5, 4.5, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`) + // Add a node with no GPS (should always be kept) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) + VALUES ('bbbb111122223333', 'NoGPSNode', 'companion', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`) + + cfg := &Config{Port: 3000, APIKey: apiKey, GeoFilter: gf} + hub := NewHub() + srv := NewServer(db, cfg, hub) + store := NewPacketStore(db, nil) + store.Load() + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + return srv, router +} + +func TestPruneGeoFilterEndpoint(t *testing.T) { + const apiKey = "a-strong-api-key-for-testing" + + // Polygon around San Jose — seed nodes are at 37.4–37.6, -122.1 to -121.9 (inside) + // OutsideNode is at 51.5, 4.5 (Europe — outside) + gf := &GeoFilterConfig{ + Polygon: [][2]float64{{37.0, -123.0}, {38.0, -123.0}, {38.0, -121.0}, {37.0, -121.0}}, + BufferKm: 0, + } + + t.Run("dry run returns outside nodes without deleting", func(t *testing.T) { + _, router := setupPruneGeoFilterServer(t, apiKey, gf) + + req := httptest.NewRequest("POST", "/api/admin/prune-geo-filter", nil) + req.Header.Set("X-API-Key", apiKey) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["dryRun"] != true { + t.Error("expected dryRun=true") + } + count, _ := body["count"].(float64) + if count != 1 { + t.Errorf("expected 1 outside node (OutsideNode), got %v", count) + } + nodes, _ := body["nodes"].([]interface{}) + if len(nodes) != 1 { + t.Fatalf("expected 1 node in preview, got %d", len(nodes)) + } + n, _ := nodes[0].(map[string]interface{}) + if n["name"] != "OutsideNode" { + t.Errorf("expected OutsideNode, got %v", n["name"]) + } + }) + + t.Run("confirm=true deletes outside nodes", func(t *testing.T) { + srv, router := setupPruneGeoFilterServer(t, apiKey, gf) + + body := strings.NewReader(`{"pubkeys":["aaaa111122223333"]}`) + req := httptest.NewRequest("POST", "/api/admin/prune-geo-filter?confirm=true", body) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["dryRun"] != false { + t.Error("expected dryRun=false") + } + deleted, _ := resp["deleted"].(float64) + if deleted != 1 { + t.Errorf("expected 1 deleted, got %v", deleted) + } + nodes, _ := resp["nodes"].([]interface{}) + if len(nodes) != 1 { + t.Errorf("expected 1 node in response, got %d", len(nodes)) + } + + // Verify node is actually gone from DB + var count int + srv.db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key = 'aaaa111122223333'").Scan(&count) + if count != 0 { + t.Error("expected OutsideNode to be deleted from DB") + } + // No-GPS node must still exist + srv.db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key = 'bbbb111122223333'").Scan(&count) + if count != 1 { + t.Error("expected NoGPSNode to be kept") + } + }) + + t.Run("confirm=true without pubkeys body returns 400", func(t *testing.T) { + _, router := setupPruneGeoFilterServer(t, apiKey, gf) + + req := httptest.NewRequest("POST", "/api/admin/prune-geo-filter?confirm=true", nil) + req.Header.Set("X-API-Key", apiKey) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("returns 400 when no geo filter configured", func(t *testing.T) { + _, router := setupPruneGeoFilterServer(t, apiKey, nil) + + req := httptest.NewRequest("POST", "/api/admin/prune-geo-filter", nil) + req.Header.Set("X-API-Key", apiKey) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + + t.Run("returns 401 without API key", func(t *testing.T) { + _, router := setupPruneGeoFilterServer(t, apiKey, gf) + + req := httptest.NewRequest("POST", "/api/admin/prune-geo-filter", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + }) +} + +func TestGetNodesForGeoPrune(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + + nodes, err := db.GetNodesForGeoPrune() + if err != nil { + t.Fatalf("GetNodesForGeoPrune: %v", err) + } + if len(nodes) == 0 { + t.Error("expected nodes to be returned") + } + // Check that nodes with lat/lon have non-nil fields + for _, n := range nodes { + if n.PubKey == "" { + t.Error("expected non-empty pubkey") + } + } +} + +func TestDeleteNodesByPubkeys(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + + // Count before + var before int + db.conn.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&before) + if before == 0 { + t.Skip("no nodes to delete") + } + + // Delete one node + var pk string + db.conn.QueryRow("SELECT public_key FROM nodes LIMIT 1").Scan(&pk) + n, err := db.DeleteNodesByPubkeys([]string{pk}) + if err != nil { + t.Fatalf("DeleteNodesByPubkeys: %v", err) + } + if n != 1 { + t.Errorf("expected 1 deleted, got %d", n) + } + + var after int + db.conn.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&after) + if after != before-1 { + t.Errorf("expected %d nodes after delete, got %d", before-1, after) + } +} diff --git a/config.example.json b/config.example.json index 7e8e80a3..4a32a897 100644 --- a/config.example.json +++ b/config.example.json @@ -169,7 +169,7 @@ [37.20, -122.52] ], "bufferKm": 20, - "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use tools/geofilter-builder.html to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." + "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or tools/geofilter-builder.html to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." }, "regions": { "SJC": "San Jose, US", diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 7ff59d94..7435fd3f 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -206,7 +206,9 @@ Provide cert and key paths to enable HTTPS. Restricts ingestion and API responses to nodes within the polygon plus a buffer margin. Remove the block to disable filtering. Nodes with no GPS fix always pass through. -See [Geographic Filtering](geofilter.md) for the full guide including the visual polygon builder and the prune script for cleaning up historical data. +Can also be configured live via the **🗺️ GeoFilter** tab in the Customizer (requires `apiKey`). + +See [Geographic Filtering](geofilter.md) for the full guide. ## Home page diff --git a/docs/user-guide/customization.md b/docs/user-guide/customization.md index 888761e2..c4d982de 100644 --- a/docs/user-guide/customization.md +++ b/docs/user-guide/customization.md @@ -66,11 +66,13 @@ Click **Import JSON** and paste a previously exported theme. The customizer load Click **Reset to Defaults** to restore all settings to the built-in defaults. -## GeoFilter Builder +## GeoFilter (admin only) -The Export tab includes a **GeoFilter Builder →** link. Click it to open a Leaflet map where you can draw a polygon boundary for your deployment area. The tool generates a `geo_filter` block you can paste directly into `config.json`. +The **🗺️ GeoFilter** tab lets operators configure geographic filtering directly from the customizer. It shows the active polygon on a Leaflet map and — on servers with a write-capable `apiKey` — allows editing the polygon and saving back to `config.json` without a restart. -See [Geographic Filtering](geofilter.md) for full details on what geo filtering does and how to configure it. +The editing controls are only revealed after the server confirms write access. On public deployments without an `apiKey`, the tab is read-only. + +See [Geographic Filtering](geofilter.md) for the full guide, including the API, the prune script, and the standalone GeoFilter Builder. ## How it works diff --git a/docs/user-guide/geofilter.md b/docs/user-guide/geofilter.md index 27d20b41..453d1915 100644 --- a/docs/user-guide/geofilter.md +++ b/docs/user-guide/geofilter.md @@ -30,9 +30,9 @@ Add a `geo_filter` block to `config.json`: | Field | Type | Description | |-------|------|-------------| | `polygon` | `[[lat, lon], ...]` | Array of at least 3 coordinate pairs defining the boundary | -| `bufferKm` | number | Extra distance (km) around the polygon edge that is also accepted. `0` = exact boundary | +| `bufferKm` | number | Extra distance (km) outside the polygon edge that is also accepted. `0` = exact boundary | -Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section. +Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section manually. To disable filtering entirely, remove the `geo_filter` block. @@ -51,50 +51,88 @@ An older bounding box format is also supported as a fallback when no `polygon` i Prefer the polygon format — it supports irregular shapes and the `bufferKm` margin. -## API endpoint +## Configuring via the customizer + +If your server has an `apiKey` configured, the **GeoFilter tab** in the Customizer lets you edit the polygon visually without touching `config.json`: + +1. Open the Customizer (nav bar → customize icon) +2. Click the **🗺️ GeoFilter** tab +3. Click on the map to draw your polygon (at least 3 points) +4. Adjust **Buffer km** +5. Enter your **Server API Key** (the `apiKey` value from `config.json`) +6. Click **Save to server** — the filter is applied immediately, no restart needed + +The editing controls only appear when the server has a write-capable API key configured. On deployments without an `apiKey`, the tab shows the current polygon as read-only. + +To remove the filter, click **Remove filter** (also requires the API key). + +## GeoFilter Builder (standalone tool) + +For a full-screen editing experience, use the built-in GeoFilter Builder at `/geofilter-builder.html`: -The current geo filter configuration is exposed at: +1. Navigate to `http://your-server/geofilter-builder.html` +2. Click on the map to add polygon vertices +3. Adjust **Buffer km** (default 20) +4. Copy the generated JSON from the output panel +5. Paste it as a top-level key into `config.json` and restart the server + +The builder is also accessible from the Customizer's Export tab via the **GeoFilter Builder →** link. + +For local/offline use without a running server, open `tools/geofilter-builder.html` directly in a browser. + +## API endpoint ``` GET /api/config/geo-filter ``` -The frontend reads this endpoint to display the active filter. No authentication is required (the endpoint returns config, not private data). +Returns the current geo filter configuration. Also includes a `writeEnabled` boolean indicating whether the `PUT` endpoint is available (i.e., server has a write-capable `apiKey`). -## GeoFilter Builder +``` +PUT /api/config/geo-filter +``` -The simplest way to create a polygon is the included visual builder: +Requires `X-API-Key` header. Saves the polygon to `config.json` and applies it in-memory immediately. -**File:** `tools/geofilter-builder.html` +Request body: +```json +{"polygon": [[lat, lon], ...], "bufferKm": 20} +``` -Open it directly in a browser — it runs entirely client-side, no server required: +To clear the filter, send `{"polygon": null}`. -```bash -# From the project root -open tools/geofilter-builder.html # macOS -xdg-open tools/geofilter-builder.html # Linux -start tools/geofilter-builder.html # Windows +``` +POST /api/admin/prune-geo-filter +POST /api/admin/prune-geo-filter?confirm=true ``` -**Workflow:** - -1. The map opens centered on Belgium by default. Navigate to your region. -2. Click on the map to add polygon vertices. Each click adds a numbered point. -3. Add at least 3 points to form a closed polygon. -4. Adjust **Buffer km** (default 20) to add a margin around the polygon edge. -5. The generated JSON block appears at the bottom of the page — copy it directly into `config.json`. -6. Use **↩ Undo** to remove the last point, **✕ Clear** to start over. +Requires `X-API-Key` header. Without `?confirm=true`, performs a dry run and returns the list of nodes that would be deleted. With `?confirm=true`, permanently deletes them from the database. -The output is a complete `{ "geo_filter": { ... } }` block ready to paste into `config.json`. +Response (dry run or confirmed): +```json +{"deleted": 5, "nodes": [{"pubKey": "...", "name": "NodeName", "lat": 51.12, "lon": 4.50}]} +``` ## Cleaning up historical nodes -The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes that were stored before the filter was configured. For that, use the prune script. +The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes stored before the filter was configured. + +### One-click prune from the Customizer (recommended) + +If `writeEnabled` is true (server has a write-capable `apiKey`), the GeoFilter tab shows a **Prune nodes** section at the bottom: + +1. Click **Preview** — the server dry-runs the deletion and lists every node that falls outside the current polygon + buffer. No data is deleted yet. +2. Review the list. It shows the node name (or public key) and coordinates. +3. Click **Confirm delete** to permanently remove those nodes from the database. + +Nodes without GPS coordinates are always kept. + +### CLI alternative (Python script) **File:** `scripts/prune-nodes-outside-geo-filter.py` ```bash -# Dry run — shows what would be deleted without making any changes +# Dry run — shows what would be deleted without making changes python3 scripts/prune-nodes-outside-geo-filter.py --dry-run # Default paths: /app/data/meshcore.db and /app/config.json @@ -104,11 +142,11 @@ python3 scripts/prune-nodes-outside-geo-filter.py python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \ --config /path/to/config.json -# In Docker — run inside the container +# In Docker docker exec -it meshcore-analyzer \ python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run ``` -The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists the nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept. +The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept. -This is a **one-time migration tool** — run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically at ingest time. +Both the UI button and the script are **one-time migration tools** — run once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically. diff --git a/public/customize-v2.js b/public/customize-v2.js index 86295463..d0d1cbce 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -784,6 +784,16 @@ var _activeTab = 'branding'; var _styleEl = null; + // GeoFilter tab state + var _gfMap = null; + var _gfModalMap = null; + var _gfWriteEnabled = false; + var _gfPoints = []; + var _gfMarkers = []; + var _gfPolygon = null; + var _gfClosingLine = null; + var _gfLoaded = false; // true after initial server load + function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/' + n + '' : ''; })() }, { id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') }, { id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' ' + n + '' : ''; })() }, + { id: 'geofilter', label: '🗺️', title: 'GeoFilter' }, { id: 'export', label: '📤', title: 'Export' } ]; return '
' + tabs.map(function (t) { @@ -1159,6 +1170,383 @@ '
'; } + function _renderGeoFilter() { + return '
' + + '

Geographic Filter

' + + '

Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.

' + + '
' + + '
' + + '
🔍 click to expand
' + + '
' + + '
Loading current filter…
' + + // Edit controls — hidden until server confirms write access (writeEnabled=true) + '' + + '
'; + } + + function _gfOpenModal(container) { + var existing = document.getElementById('cv2-gf-modal-overlay'); + if (existing) existing.remove(); + if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } + + var overlay = document.createElement('div'); + overlay.id = 'cv2-gf-modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:99999;display:flex;align-items:center;justify-content:center;'; + + var dialog = document.createElement('div'); + dialog.style.cssText = 'width:92vw;height:86vh;background:#fff;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);'; + + var toolbarEl = document.createElement('div'); + toolbarEl.style.cssText = 'padding:10px 14px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #e0e0e0;background:#f5f5f5;flex-shrink:0;'; + var title = document.createElement('span'); + title.style.cssText = 'font-weight:600;color:#333;font-size:14px;'; + title.textContent = _gfWriteEnabled ? 'Edit GeoFilter — click map to add points' : 'GeoFilter — read only'; + toolbarEl.appendChild(title); + + if (_gfWriteEnabled) { + var undoBtn = document.createElement('button'); + undoBtn.id = 'cv2-gfm-undo'; + undoBtn.textContent = '↩ Undo'; + undoBtn.style.cssText = 'padding:5px 10px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:12px;'; + var clearBtn = document.createElement('button'); + clearBtn.id = 'cv2-gfm-clear'; + clearBtn.textContent = '✕ Clear'; + clearBtn.style.cssText = 'padding:5px 10px;background:#fee;color:#c44;border:1px solid #fcc;border-radius:6px;cursor:pointer;font-size:12px;'; + var countEl = document.createElement('span'); + countEl.id = 'cv2-gfm-count'; + countEl.style.cssText = 'font-size:12px;color:#888;'; + var spacer = document.createElement('span'); + spacer.style.cssText = 'flex:1;'; + var doneBtn = document.createElement('button'); + doneBtn.id = 'cv2-gfm-done'; + doneBtn.textContent = 'Done'; + doneBtn.style.cssText = 'padding:7px 18px;background:#4a9eff;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;'; + toolbarEl.appendChild(undoBtn); + toolbarEl.appendChild(clearBtn); + toolbarEl.appendChild(countEl); + toolbarEl.appendChild(spacer); + toolbarEl.appendChild(doneBtn); + } else { + var spacer2 = document.createElement('span'); + spacer2.style.cssText = 'flex:1;'; + toolbarEl.appendChild(spacer2); + } + + var closeBtn = document.createElement('button'); + closeBtn.id = 'cv2-gfm-close'; + closeBtn.textContent = _gfWriteEnabled ? 'Cancel' : 'Close'; + closeBtn.style.cssText = 'padding:7px 14px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:13px;'; + toolbarEl.appendChild(closeBtn); + + var mapDiv = document.createElement('div'); + mapDiv.id = 'cv2-gf-modal-map'; + mapDiv.style.cssText = 'flex:1;'; + + dialog.appendChild(toolbarEl); + dialog.appendChild(mapDiv); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + var modalPoints = _gfPoints.map(function (p) { return [p[0], p[1]]; }); + var modalMarkers = []; + var modalPolygon = null; + var modalClosingLine = null; + + _gfModalMap = L.map(mapDiv, { zoomControl: true }); + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 + }).addTo(_gfModalMap); + + function renderModal() { + if (modalPolygon) { _gfModalMap.removeLayer(modalPolygon); modalPolygon = null; } + if (modalClosingLine) { _gfModalMap.removeLayer(modalClosingLine); modalClosingLine = null; } + modalMarkers.forEach(function (m) { _gfModalMap.removeLayer(m); }); + modalMarkers = []; + modalPoints.forEach(function (pt, i) { + var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 }) + .addTo(_gfModalMap) + .bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] }); + modalMarkers.push(m); + }); + if (modalPoints.length >= 3) { + modalPolygon = L.polygon(modalPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfModalMap); + } else if (modalPoints.length === 2) { + modalClosingLine = L.polyline(modalPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfModalMap); + } + var ce = document.getElementById('cv2-gfm-count'); + if (ce) ce.textContent = modalPoints.length + ' point' + (modalPoints.length !== 1 ? 's' : ''); + } + + function closeModal() { + if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } + overlay.remove(); + } + + setTimeout(function () { + _gfModalMap.invalidateSize(); + renderModal(); + if (modalPoints.length >= 3) { + _gfModalMap.fitBounds(L.latLngBounds(modalPoints), { padding: [40, 40] }); + } else { + _gfModalMap.setView([50.5, 4.4], 5); + } + }, 80); + + if (_gfWriteEnabled) { + _gfModalMap.on('click', function (e) { + modalPoints.push([parseFloat(e.latlng.lat.toFixed(6)), parseFloat(e.latlng.lng.toFixed(6))]); + renderModal(); + }); + document.getElementById('cv2-gfm-undo').addEventListener('click', function () { + if (!modalPoints.length) return; + modalPoints.pop(); + renderModal(); + }); + document.getElementById('cv2-gfm-clear').addEventListener('click', function () { + modalPoints = []; + renderModal(); + }); + document.getElementById('cv2-gfm-done').addEventListener('click', function () { + _gfPoints = modalPoints; + _gfRender(); + var prune = container.querySelector('#cv2-gf-prune-section'); + if (prune) prune.style.display = _gfPoints.length >= 3 ? '' : 'none'; + _gfStatus(container, _gfPoints.length + ' point' + (_gfPoints.length !== 1 ? 's' : '') + '.'); + closeModal(); + }); + } + + closeBtn.addEventListener('click', closeModal); + overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); }); + } + + function _gfRender() { + if (!_gfMap) return; + if (_gfPolygon) { _gfMap.removeLayer(_gfPolygon); _gfPolygon = null; } + if (_gfClosingLine) { _gfMap.removeLayer(_gfClosingLine); _gfClosingLine = null; } + _gfMarkers.forEach(function (m) { _gfMap.removeLayer(m); }); + _gfMarkers = []; + + _gfPoints.forEach(function (pt, i) { + var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 }) + .addTo(_gfMap) + .bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] }); + _gfMarkers.push(m); + }); + + if (_gfPoints.length >= 3) { + _gfPolygon = L.polygon(_gfPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfMap); + } else if (_gfPoints.length === 2) { + _gfClosingLine = L.polyline(_gfPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfMap); + } + } + + function _gfStatus(container, msg) { + var el = container.querySelector('#cv2-gf-status'); + if (el) el.textContent = msg; + } + + function _gfMsg(container, msg, ok) { + var el = container.querySelector('#cv2-gf-msg'); + if (!el) return; + el.textContent = msg; + el.style.display = msg ? '' : 'none'; + el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)'; + } + + function _gfSave(container) { + if (_gfPoints.length < 3) { _gfMsg(container, 'Need at least 3 polygon points.', false); return; } + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfMsg(container, 'API key required to save.', false); return; } + var bufferKm = parseFloat((container.querySelector('#cv2-gf-buffer') || {}).value) || 0; + fetch('/api/config/geo-filter', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ polygon: _gfPoints, bufferKm: bufferKm }) + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + _gfMsg(container, 'Saved. Filter is active immediately.', true); + _gfStatus(container, _gfPoints.length + ' points · bufferKm=' + bufferKm + ' · saved'); + }).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); }); + } + + function _gfRemove(container) { + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfMsg(container, 'API key required.', false); return; } + if (!confirm('Remove geo filter? All nodes will be allowed through.')) return; + fetch('/api/config/geo-filter', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ polygon: null }) + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + _gfPoints = []; _gfLoaded = true; + _gfRender(); + _gfStatus(container, 'No geo filter. Click the map to draw a polygon.'); + _gfMsg(container, 'Geo filter removed.', true); + }).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); }); + } + + var _gfPruneNodes = []; // nodes returned by last dry-run preview + + function _gfPruneMsg(container, msg, ok) { + var el = container.querySelector('#cv2-gf-prune-msg'); + if (!el) return; + el.textContent = msg; + el.style.display = msg ? '' : 'none'; + el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)'; + } + + function _gfPrunePreview(container) { + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; } + var btn = container.querySelector('#cv2-gf-prune-preview'); + if (btn) btn.textContent = 'Loading…'; + fetch('/api/admin/prune-geo-filter', { + method: 'POST', + headers: { 'X-API-Key': apiKey } + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + return r.json(); + }).then(function (data) { + if (btn) btn.textContent = 'Preview prune'; + _gfPruneNodes = data.nodes || []; + var count = data.count || 0; + var resultEl = container.querySelector('#cv2-gf-prune-result'); + var listEl = container.querySelector('#cv2-gf-prune-list'); + var confirmBtn = container.querySelector('#cv2-gf-prune-confirm'); + if (!resultEl || !listEl || !confirmBtn) return; + if (count === 0) { + _gfPruneMsg(container, 'No nodes outside the filter. Nothing to prune.', true); + resultEl.style.display = 'none'; + return; + } + listEl.innerHTML = _gfPruneNodes.map(function (n) { + var coords = n.lat != null ? (' · ' + n.lat.toFixed(4) + ', ' + n.lon.toFixed(4)) : ''; + return '
' + (n.name || n.pubkey.slice(0, 12)) + coords + '
'; + }).join(''); + confirmBtn.textContent = 'Delete ' + count + ' node' + (count !== 1 ? 's' : ''); + resultEl.style.display = ''; + _gfPruneMsg(container, '', true); + }).catch(function (e) { + if (btn) btn.textContent = 'Preview prune'; + _gfPruneMsg(container, 'Error: ' + e.message, false); + }); + } + + function _gfPruneConfirm(container) { + if (!_gfPruneNodes.length) { _gfPruneMsg(container, 'Run preview first.', false); return; } + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; } + var count = _gfPruneNodes.length; + if (!confirm('Delete ' + count + ' node' + (count !== 1 ? 's' : '') + ' from the database? This cannot be undone.')) return; + var pubkeys = _gfPruneNodes.map(function (n) { return n.pubkey; }); + fetch('/api/admin/prune-geo-filter?confirm=true', { + method: 'POST', + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkeys: pubkeys }) + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + return r.json(); + }).then(function (data) { + _gfPruneNodes = []; + var resultEl = container.querySelector('#cv2-gf-prune-result'); + if (resultEl) resultEl.style.display = 'none'; + var n = data.deleted; + _gfPruneMsg(container, 'Deleted ' + n + ' node' + (n !== 1 ? 's' : '') + '.', true); + }).catch(function (e) { _gfPruneMsg(container, 'Error: ' + e.message, false); }); + } + + function _initGeoFilterTab(container) { + var mapEl = container.querySelector('#cv2-gf-map'); + if (!mapEl || typeof L === 'undefined') return; + + _gfMap = L.map(mapEl, { zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false }); + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 + }).addTo(_gfMap); + + if (!_gfLoaded) { + api('/config/geo-filter', { ttl: 0 }).then(function (gf) { + // Show edit controls only on servers that have a write-capable API key configured + if (gf && gf.writeEnabled) { + _gfWriteEnabled = true; + var editEl = container.querySelector('#cv2-gf-edit'); + if (editEl) editEl.style.display = ''; + } + if (gf && gf.polygon && gf.polygon.length >= 3) { + _gfPoints = gf.polygon.map(function (p) { return [p[0], p[1]]; }); + var buf = container.querySelector('#cv2-gf-buffer'); + if (buf) buf.value = gf.bufferKm || 0; + _gfRender(); + if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] }); + _gfStatus(container, gf.polygon.length + ' points · bufferKm=' + (gf.bufferKm || 0)); + // Show prune section when a polygon is active and write access is available + if (gf.writeEnabled) { + var pruneEl = container.querySelector('#cv2-gf-prune-section'); + if (pruneEl) pruneEl.style.display = ''; + } + } else { + _gfPoints = []; + _gfStatus(container, gf && gf.writeEnabled ? 'No geo filter. Click the map to open the editor.' : 'No geo filter configured.'); + _gfMap.setView([50.5, 4.4], 5); + } + _gfLoaded = true; + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + }).catch(function () { + _gfStatus(container, 'Could not load current filter.'); + _gfMap.setView([50.5, 4.4], 5); + _gfLoaded = true; + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + }); + } else { + if (_gfPoints.length >= 3) { + _gfRender(); + if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] }); + _gfStatus(container, _gfPoints.length + ' points.'); + } else { + _gfMap.setView([50.5, 4.4], 5); + _gfStatus(container, _gfPoints.length ? _gfPoints.length + ' points (need at least 3).' : 'Click the map to draw a polygon.'); + _gfRender(); + } + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + } + + _gfMap.on('click', function () { _gfOpenModal(container); }); + + container.querySelector('#cv2-gf-save').addEventListener('click', function () { _gfSave(container); }); + container.querySelector('#cv2-gf-remove').addEventListener('click', function () { _gfRemove(container); }); + + var prunePreviewBtn = container.querySelector('#cv2-gf-prune-preview'); + var pruneConfirmBtn = container.querySelector('#cv2-gf-prune-confirm'); + if (prunePreviewBtn) prunePreviewBtn.addEventListener('click', function () { _gfPrunePreview(container); }); + if (pruneConfirmBtn) pruneConfirmBtn.addEventListener('click', function () { _gfPruneConfirm(container); }); + } + function _renderExport() { var delta = readOverrides(); var json = JSON.stringify(delta, null, 2); @@ -1193,6 +1581,7 @@ _renderNodes() + _renderHome() + _renderDisplay() + + _renderGeoFilter() + _renderExport() + ''; _bindEvents(container); @@ -1261,11 +1650,15 @@ // Tab switching container.querySelectorAll('.cust-tab').forEach(function (btn) { btn.addEventListener('click', function () { + if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove(); _activeTab = btn.dataset.tab; _renderPanel(container); }); }); + // GeoFilter tab init + if (_activeTab === 'geofilter') _initGeoFilterTab(container); + // Preset buttons container.querySelectorAll('.cust-preset-btn').forEach(function (btn) { btn.addEventListener('click', function () { @@ -1530,7 +1923,10 @@ ''; document.body.appendChild(_panelEl); - _panelEl.querySelector('.cust-close').addEventListener('click', function () { _panelEl.classList.add('hidden'); }); + _panelEl.querySelector('.cust-close').addEventListener('click', function () { + if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove(); + _panelEl.classList.add('hidden'); + }); // Drag support var header = _panelEl.querySelector('.cust-header'); diff --git a/public/geofilter-builder.html b/public/geofilter-builder.html index 1aa18ac7..a67dd8e0 100644 --- a/public/geofilter-builder.html +++ b/public/geofilter-builder.html @@ -8,32 +8,32 @@ @@ -70,13 +70,13 @@

GeoFilter Builder

Copy the JSON above → paste as a top-level key in config.json → restart the server. Nodes with no GPS fix always pass through. Remove the geo_filter block to disable filtering. -  ·  Documentation +  ·  Documentation ↗