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 '
Geographic Filter
' + + 'Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.
' + + 'config.json β restart the server.
Nodes with no GPS fix always pass through. Remove the geo_filter block to disable filtering.
- Β· Documentation
+ Β· Documentation β