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/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..6b7c5113 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") @@ -430,12 +448,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 +1167,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) } } diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 4ac15f54..c88e2a84 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,196 @@ 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") + } + }) +} 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..c61852d6 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,64 @@ 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 -The current geo filter configuration is exposed at: +If your server has an `apiKey` configured, the **GeoFilter tab** in the Customizer lets you edit the polygon visually without touching `config.json`: -``` -GET /api/config/geo-filter -``` +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 frontend reads this endpoint to display the active filter. No authentication is required (the endpoint returns config, not private data). +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. -## GeoFilter Builder +To remove the filter, click **Remove filter** (also requires the API key). -The simplest way to create a polygon is the included visual builder: +## GeoFilter Builder (standalone tool) -**File:** `tools/geofilter-builder.html` +For a full-screen editing experience, use the built-in GeoFilter Builder at `/geofilter-builder.html`: -Open it directly in a browser β€” it runs entirely client-side, no server required: +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 -```bash -# From the project root -open tools/geofilter-builder.html # macOS -xdg-open tools/geofilter-builder.html # Linux -start tools/geofilter-builder.html # Windows +``` +GET /api/config/geo-filter +``` + +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`). + +``` +PUT /api/config/geo-filter ``` -**Workflow:** +Requires `X-API-Key` header. Saves the polygon to `config.json` and applies it in-memory immediately. -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. +Request body: +```json +{"polygon": [[lat, lon], ...], "bufferKm": 20} +``` -The output is a complete `{ "geo_filter": { ... } }` block ready to paste into `config.json`. +To clear the filter, send `{"polygon": null}`. ## 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. For that, use the prune 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 +118,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. +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. diff --git a/public/customize-v2.js b/public/customize-v2.js index 86295463..003f5514 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,292 @@ '
'; } + 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); }); + } + + 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)); + } 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); }); + } + function _renderExport() { var delta = readOverrides(); var json = JSON.stringify(delta, null, 2); @@ -1193,6 +1490,7 @@ _renderNodes() + _renderHome() + _renderDisplay() + + _renderGeoFilter() + _renderExport() + ''; _bindEvents(container); @@ -1261,11 +1559,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 +1832,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 β†—