diff --git a/Dockerfile b/Dockerfile index 38cf0762..1279d179 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/ COPY internal/packetpath/ ../../internal/packetpath/ COPY internal/dbconfig/ ../../internal/dbconfig/ COPY internal/perfio/ ../../internal/perfio/ +COPY internal/dbschema/ ../../internal/dbschema/ RUN go mod download COPY cmd/server/ ./ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ @@ -33,6 +34,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/ COPY internal/packetpath/ ../../internal/packetpath/ COPY internal/dbconfig/ ../../internal/dbconfig/ COPY internal/perfio/ ../../internal/perfio/ +COPY internal/dbschema/ ../../internal/dbschema/ RUN go mod download COPY cmd/ingestor/ ./ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ diff --git a/cmd/server/config.go b/cmd/server/config.go index 047ac041..93ecc7c5 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" @@ -447,6 +448,68 @@ 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') + + // Preserve the original file mode so operators' chmod 0600 survives the write. + origMode := os.FileMode(0644) + if fi, err := os.Stat(configPath); err == nil { + origMode = fi.Mode().Perm() + } + + // Atomic write: temp file + rename. + tmp := configPath + ".tmp" + if err := os.WriteFile(tmp, out, origMode); 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 +} + // obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups. func (c *Config) obsBlacklistSet() map[string]bool { c.obsBlacklistOnce.Do(func() { @@ -485,8 +548,8 @@ func (c *Config) IsObserverBlacklisted(id string) bool { // RecomputeIntervalSeconds keys (all optional): // topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew type AnalyticsConfig struct { - DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"` - RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"` + DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"` + RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"` } // AnalyticsDefaultRecomputeInterval returns the configured default diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index 36e59ea9..8323dbd2 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" ) @@ -387,3 +388,25 @@ func TestObserverDaysOrDefault(t *testing.T) { }) } } + +func TestSaveGeoFilter_PreservesFileMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file permissions not supported on Windows") + } + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"port":3000}`), 0600); err != nil { + t.Fatal(err) + } + gf := &GeoFilterConfig{Polygon: [][2]float64{{1, 2}, {3, 4}, {5, 6}}, BufferKm: 0} + if err := SaveGeoFilter(dir, gf); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Errorf("file mode downgraded: want 0600, got %04o", got) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 7bb27b06..1a1dbce0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -273,6 +273,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 62d1d0ea..b72b06e2 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,20 @@ 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 + + // Serializes concurrent PUT /api/config/geo-filter disk writes so requests + // can't race on the .tmp file or interleave disk/memory updates. + saveMu sync.Mutex + // Cached runtime.MemStats to avoid stop-the-world pauses on every health check memStatsMu sync.Mutex memStatsCache runtime.MemStats @@ -59,6 +68,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 @@ -120,6 +141,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") // Readiness endpoint (gated on background init completion) r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET") @@ -444,7 +466,11 @@ 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() + // NOTE: do NOT include any field that derives from APIKey presence/strength here. + // This endpoint is intentionally public; leaking whether a write-capable key is + // configured is an info-disclosure. Clients that want to write should just try + // PUT and handle 401/403. See PR #736 review. if gf == nil || len(gf.Polygon) == 0 { writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0}) return @@ -452,6 +478,66 @@ func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm}) } +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 + } + 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 + } + } + + // bufferKm must be finite, non-negative, and ≤ 20000 km (half Earth circumference). + if math.IsNaN(body.BufferKm) || math.IsInf(body.BufferKm, 0) || + body.BufferKm < 0 || body.BufferKm > 20000 { + writeError(w, http.StatusBadRequest, "bufferKm must be a finite number in [0, 20000]") + return + } + + var gf *GeoFilterConfig + if len(body.Polygon) >= 3 { + gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm} + } + + s.saveMu.Lock() + if s.configDir != "" { + if err := SaveGeoFilter(s.configDir, gf); err != nil { + s.saveMu.Unlock() + log.Printf("[geofilter] save failed: %v", err) + writeError(w, http.StatusInternalServerError, "failed to save config") + return + } + } + s.setGeoFilter(gf) + s.saveMu.Unlock() + + 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 --- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -1145,20 +1231,17 @@ 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 { // Foreign-flagged nodes (#730) are kept even when their GPS lies - // outside the geofilter polygon — that's the whole point of the - // flag: operators need to SEE bridged/leaked nodes, not have them - // filtered away. The ingestor sets foreign_advert=1 when its - // configured geo_filter rejected the advert; the server must - // surface those. + // outside the geofilter polygon — operators need to SEE bridged/ + // leaked nodes, not have them filtered away. if isForeign, _ := node["foreign"].(bool); isForeign { filtered = append(filtered, node) continue } - 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 b0bd6195..2ddc5b2e 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -3,10 +3,14 @@ package main import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" + "sync" "testing" "time" @@ -2839,6 +2843,32 @@ func TestConfigGeoFilterEndpoint(t *testing.T) { if body["bufferKm"] == nil { t.Error("expected bufferKm in response") } + // writeEnabled must NOT be leaked: the public GET endpoint should not + // disclose whether a strong apiKey is configured to unauthenticated callers. + if _, ok := body["writeEnabled"]; ok { + t.Errorf("writeEnabled must not be present in public GET response (info disclosure), got %v", body["writeEnabled"]) + } + }) + + t.Run("writeEnabled is not exposed even 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 _, ok := body["writeEnabled"]; ok { + t.Errorf("writeEnabled must not be present (would leak apiKey presence), got %v", body["writeEnabled"]) + } }) } @@ -3973,3 +4003,262 @@ 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) + } + }) + + t.Run("rejects negative bufferKm", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":-1}` + 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 negative bufferKm, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("rejects excessive bufferKm", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":99999999}` + 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 excessive bufferKm, got %d: %s", w.Code, w.Body.String()) + } + }) +} + +func TestPutConfigGeoFilter_ConcurrentSafe(t *testing.T) { + const apiKey = "a-strong-api-key-for-testing" + srv, router, dir := setupGeoFilterServer(t, apiKey) + _ = srv + + const n = 10 + var wg sync.WaitGroup + errs := make(chan string, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + lat := 51.0 + float64(i)*0.001 + body := fmt.Sprintf(`{"polygon":[[%f,4.0],[%f,5.0],[50.5,4.0]],"bufferKm":0}`, lat, lat) + 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 { + errs <- fmt.Sprintf("goroutine %d: got %d: %s", i, w.Code, w.Body.String()) + } + }(i) + } + wg.Wait() + close(errs) + for e := range errs { + t.Error(e) + } + // config.json must be valid JSON after concurrent writes + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + t.Errorf("config.json corrupted after concurrent PUTs: %v\ncontents: %s", err, data) + } +} + +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 64b23f03..ae15cab0 100644 --- a/config.example.json +++ b/config.example.json @@ -175,7 +175,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 the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. 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 the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." }, "foreignAdverts": { "mode": "flag", diff --git a/docs/superpowers/plans/2026-04-05-deep-linking-p1.md b/docs/superpowers/plans/2026-04-05-deep-linking-p1.md deleted file mode 100644 index 23a23ba4..00000000 --- a/docs/superpowers/plans/2026-04-05-deep-linking-p1.md +++ /dev/null @@ -1,674 +0,0 @@ -# Deep Linking P1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared. - -**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present. - -**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams - ---- - -## Files Changed - -| File | Changes | -|---|---| -| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render | -| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` | -| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` | -| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` | -| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` | -| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence | - ---- - -## Task 1: Add `setSelected` to RegionFilter - -**Files:** -- Modify: `public/region-filter.js` - -- [ ] **Step 1: Write the failing unit test** - -Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line: - -```javascript -// ===== REGION-FILTER.JS: setSelected ===== -console.log('\n=== region-filter.js: setSelected ==='); -{ - const ctx = makeSandbox(); - ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) }); - loadInCtx(ctx, 'public/region-filter.js'); - - const RF = ctx.RegionFilter; - RF.init(document.createElement('div')); - - test('setSelected sets region codes', async () => { - await RF.init(document.createElement('div')); - RF.setSelected(['US-SFO', 'US-LAX']); - assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX'); - }); - - test('setSelected with null clears selection', async () => { - await RF.init(document.createElement('div')); - RF.setSelected(['US-SFO']); - RF.setSelected(null); - assert.strictEqual(RF.getRegionParam(), ''); - }); - - test('setSelected with empty array clears selection', async () => { - await RF.init(document.createElement('div')); - RF.setSelected(['US-SFO']); - RF.setSelected([]); - assert.strictEqual(RF.getRegionParam(), ''); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected" -``` - -Expected: `❌ setSelected sets region codes: RF.setSelected is not a function` - -- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js** - -In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`): - -```javascript - var _listeners = []; - var _container = null; // ← add this line - var _loaded = false; -``` - -In `initFilter`, save the container: - -```javascript - async function initFilter(container, opts) { - _container = container; // ← add this line - if (opts && opts.dropdown) container._forceDropdown = true; - await fetchRegions(); - render(container); - } -``` - -Add `setSelected` function before `// Expose globally`: - -```javascript - /** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */ - function setSelected(codesArray) { - _selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null; - saveToStorage(); - if (_container) render(_container); - } -``` - -Add `setSelected` to the public API object: - -```javascript - window.RegionFilter = { - init: initFilter, - render: render, - getSelected: getSelected, - getRegionParam: getRegionParam, - regionQueryString: regionQueryString, - onChange: onChange, - offChange: offChange, - fetchRegions: fetchRegions, - setSelected: setSelected, // ← add this line - }; -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)" -``` - -Expected: 3 passing `setSelected` tests, overall pass. - -- [ ] **Step 5: Commit** - -```bash -git add public/region-filter.js test-frontend-helpers.js -git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)" -``` - ---- - -## Task 2: nodes.js — tab and search deep linking - -**Files:** -- Modify: `public/nodes.js` -- Test: `test-frontend-helpers.js` -- Test: `test-e2e-playwright.js` - -- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)** - -Add before the `// ===== SUMMARY =====` line: - -```javascript -// ===== NODES.JS: buildNodesQuery ===== -console.log('\n=== nodes.js: buildNodesQuery ==='); -{ - const ctx = makeSandbox(); - loadInCtx(ctx, 'public/roles.js'); - loadInCtx(ctx, 'public/app.js'); - - // Provide required globals for nodes.js IIFE to execute - ctx.registerPage = () => {}; - ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' }; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.debouncedOnWS = () => () => {}; - ctx.invalidateApiCache = () => {}; - ctx.favStar = () => ''; - ctx.bindFavStars = () => {}; - ctx.getFavorites = () => []; - ctx.isFavorite = () => false; - ctx.connectWS = () => {}; - ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false }; - ctx.initTabBar = () => {}; - ctx.debounce = (fn) => fn; - ctx.copyToClipboard = () => {}; - ctx.api = () => Promise.resolve({}); - ctx.escapeHtml = (s) => s; - ctx.timeAgo = () => ''; - ctx.formatTimestampWithTooltip = () => ''; - ctx.getTimestampMode = () => 'ago'; - ctx.CLIENT_TTL = {}; - ctx.qrcode = null; - - try { - const src = fs.readFileSync('public/nodes.js', 'utf8'); - vm.runInContext(src, ctx); - for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; - } catch (e) { - console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120)); - } - - const buildNodesQuery = ctx.buildNodesQuery; - - if (buildNodesQuery) { - test('buildNodesQuery: all tab + no search = empty', () => { - assert.strictEqual(buildNodesQuery('all', ''), ''); - }); - test('buildNodesQuery: repeater tab only', () => { - assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater'); - }); - test('buildNodesQuery: search only (all tab)', () => { - assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo'); - }); - test('buildNodesQuery: tab + search combined', () => { - assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar'); - }); - test('buildNodesQuery: null search treated as empty', () => { - assert.strictEqual(buildNodesQuery('all', null), ''); - }); - test('buildNodesQuery: sensor tab', () => { - assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor'); - }); - } else { - console.log(' ⚠️ buildNodesQuery not exposed — skipping'); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails (or skips)** - -```bash -node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery" -``` - -Expected: `⚠️ buildNodesQuery not exposed — skipping` - -- [ ] **Step 3: Add URL param reading and helpers to nodes.js** - -**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`): - -```javascript - function buildNodesQuery(tab, searchStr) { - var parts = []; - if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab)); - if (searchStr) parts.push('search=' + encodeURIComponent(searchStr)); - return parts.length ? '?' + parts.join('&') : ''; - } - window.buildNodesQuery = buildNodesQuery; - - function updateNodesUrl() { - history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search)); - } -``` - -**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`: - -```javascript - // Read URL params for list view (router strips query string from routeParam) - const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); - const _urlTab = _listUrlParams.get('tab'); - const _urlSearch = _listUrlParams.get('search'); - if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab; - if (_urlSearch) search = _urlSearch; - - app.innerHTML = `
-``` - -**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input: - -```javascript - if (search) { - var _si = document.getElementById('nodeSearch'); - if (_si) _si.value = search; - } -``` - -**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`: - -```javascript - document.getElementById('nodeSearch').addEventListener('input', debounce(e => { - search = e.target.value; - updateNodesUrl(); - loadNodes(); - }, 250)); -``` - -**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`: - -```javascript - btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); }); -``` - -- [ ] **Step 4: Run unit tests** - -```bash -node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers" -``` - -Expected: 6 passing `buildNodesQuery` tests. - -- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)** - -Add before the closing `await browser.close()` line: - -```javascript - // --- Group: Deep linking (#536) --- - - // Test: nodes tab deep link - await test('Nodes tab deep link restores active tab', async () => { - await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.node-tab', { timeout: 8000 }); - const activeTab = await page.$('.node-tab.active'); - assert(activeTab, 'No active tab found'); - const tabText = await activeTab.textContent(); - assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`); - const url = page.url(); - assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`); - }); - - // Test: nodes tab click updates URL - await test('Nodes tab click updates URL', async () => { - await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.node-tab', { timeout: 8000 }); - const roomTab = await page.$('.node-tab[data-tab="room"]'); - if (roomTab) { - await roomTab.click(); - await page.waitForTimeout(300); - const url = page.url(); - assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`); - } - }); -``` - -- [ ] **Step 6: Run full test suite** - -```bash -node test-frontend-helpers.js -``` - -Expected: all tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js -git commit -m "feat: deep link nodes tab and search query (#536)" -``` - ---- - -## Task 3: packets.js — timeWindow and region deep linking - -**Files:** -- Modify: `public/packets.js` -- Test: `test-frontend-helpers.js` -- Test: `test-e2e-playwright.js` - -> Depends on Task 1 (RegionFilter.setSelected). - -- [ ] **Step 1: Write the unit test** - -Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`: - -```javascript -// ===== PACKETS.JS: buildPacketsUrl ===== -console.log('\n=== packets.js: buildPacketsUrl ==='); -{ - // Test the pure helper function - // (loaded via packets.js after it exposes window.buildPacketsUrl) - const ctx = makeSandbox(); - loadInCtx(ctx, 'public/roles.js'); - loadInCtx(ctx, 'public/app.js'); - - ctx.registerPage = () => {}; - ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} }; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.debouncedOnWS = () => () => {}; - ctx.invalidateApiCache = () => {}; - ctx.api = () => Promise.resolve({}); - ctx.observerMap = new Map(); - ctx.getParsedPath = () => []; - ctx.getParsedDecoded = () => ({}); - ctx.clearParsedCache = () => {}; - ctx.escapeHtml = (s) => s; - ctx.timeAgo = () => ''; - ctx.formatTimestampWithTooltip = () => ''; - ctx.getTimestampMode = () => 'ago'; - ctx.copyToClipboard = () => {}; - ctx.CLIENT_TTL = {}; - ctx.debounce = (fn) => fn; - ctx.initTabBar = () => {}; - - try { - const src = fs.readFileSync('public/packet-helpers.js', 'utf8'); - vm.runInContext(src, ctx); - for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; - const src2 = fs.readFileSync('public/packets.js', 'utf8'); - vm.runInContext(src2, ctx); - for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; - } catch (e) { - console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120)); - } - - const buildPacketsUrl = ctx.buildPacketsUrl; - - if (buildPacketsUrl) { - test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => { - assert.strictEqual(buildPacketsUrl(15, ''), '#/packets'); - }); - test('buildPacketsUrl: non-default timeWindow', () => { - assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60'); - }); - test('buildPacketsUrl: region only', () => { - assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO'); - }); - test('buildPacketsUrl: timeWindow + region', () => { - assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX'); - }); - test('buildPacketsUrl: timeWindow=0 treated as default', () => { - assert.strictEqual(buildPacketsUrl(0, ''), '#/packets'); - }); - } else { - console.log(' ⚠️ buildPacketsUrl not exposed — skipping'); - } -} -``` - -- [ ] **Step 2: Run to verify it skips** - -```bash -node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl" -``` - -Expected: `⚠️ buildPacketsUrl not exposed — skipping` - -- [ ] **Step 3: Add helpers and URL param reading to packets.js** - -**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`): - -```javascript - function buildPacketsUrl(timeWindowMin, regionParam) { - var parts = []; - if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin); - if (regionParam) parts.push('region=' + encodeURIComponent(regionParam)); - return '#/packets' + (parts.length ? '?' + parts.join('&') : ''); - } - window.buildPacketsUrl = buildPacketsUrl; - - function updatePacketsUrl() { - history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam())); - } -``` - -**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`: - -```javascript - // Read URL params for filter state (router strips query from routeParam; read from location.hash) - var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); - var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); - if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { - savedTimeWindowMin = _urlTimeWindow; - localStorage.setItem('meshcore-time-window', String(_urlTimeWindow)); - } - var _urlRegion = _initUrlParams.get('region'); - if (_urlRegion) { - RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean)); - } - - app.innerHTML = `
-``` - -**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`: - -```javascript - fTimeWindow.addEventListener('change', () => { - savedTimeWindowMin = Number(fTimeWindow.value); - if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15; - localStorage.setItem('meshcore-time-window', fTimeWindow.value); - updatePacketsUrl(); - loadPackets(); - }); -``` - -**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`: - -```javascript - RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); -``` - -- [ ] **Step 4: Run unit tests** - -```bash -node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers" -``` - -Expected: 5 passing `buildPacketsUrl` tests. - -- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)** - -```javascript - // Test: packets timeWindow deep link - await test('Packets timeWindow deep link restores dropdown', async () => { - await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); - const val = await page.$eval('#fTimeWindow', el => el.value); - assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`); - const url = page.url(); - assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`); - }); - - // Test: timeWindow change updates URL - await test('Packets timeWindow change updates URL', async () => { - await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); - await page.selectOption('#fTimeWindow', '30'); - await page.waitForTimeout(300); - const url = page.url(); - assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`); - }); -``` - -- [ ] **Step 6: Run full test suite** - -```bash -node test-frontend-helpers.js -``` - -Expected: all tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js -git commit -m "feat: deep link packets timeWindow and region filter (#536)" -``` - ---- - -## Task 4: channels.js — node panel deep linking - -**Files:** -- Modify: `public/channels.js` - -No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it. - -- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)** - -```javascript - // Test: channels selected channel survives refresh (already implemented, verify it still works) - await test('Channels channel selection is URL-addressable', async () => { - await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null); - const firstChannel = await page.$('.ch-item'); - if (firstChannel) { - await firstChannel.click(); - await page.waitForTimeout(500); - const url = page.url(); - assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`); - } - }); -``` - -- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL** - -In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`: - -```javascript - async function showNodeDetail(name) { - _nodePanelTrigger = document.activeElement; - if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } - const node = await lookupNode(name); - selectedNode = name; - var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; - history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name)); - - let panel = document.getElementById('chNodePanel'); -``` - -- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL** - -In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`: - -```javascript - function closeNodeDetail() { - if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } - const panel = document.getElementById('chNodePanel'); - if (panel) panel.classList.remove('open'); - selectedNode = null; - var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; - history.replaceState(null, '', _chRestoreUrl); - if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') { -``` - -- [ ] **Step 4: Read `?node=` on init and auto-open panel** - -In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`): - -```javascript - function init(app, routeParam) { - var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); - var _pendingNode = _initUrlParams.get('node'); - - app.innerHTML = `
-``` - -Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel: - -```javascript - loadChannels().then(async function () { - if (routeParam) await selectChannel(routeParam); - if (_pendingNode) showNodeDetail(_pendingNode); - }); -``` - -- [ ] **Step 5: Run full test suite** - -```bash -node test-frontend-helpers.js -``` - -Expected: all tests pass (no channels unit tests, but regression tests still pass). - -- [ ] **Step 6: Commit** - -```bash -git add public/channels.js -git commit -m "feat: deep link channels node panel via ?node= (#536)" -``` - ---- - -## Task 5: Run E2E Playwright tests - -- [ ] **Step 1: Start the local server** - -```bash -cd cmd/server && go run . & -``` - -Wait for it to be ready (check `http://localhost:3000`). - -- [ ] **Step 2: Run Playwright tests** - -```bash -node test-e2e-playwright.js -``` - -Expected: all tests pass including the new deep-linking group. - -- [ ] **Step 3: If any deep-linking test fails, debug** - -Common failures: -- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering -- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built -- URL doesn't update: check `history.replaceState` calls in the change handlers - -- [ ] **Step 4: Final commit (if any fixes needed)** - -```bash -git add public/nodes.js public/packets.js public/channels.js -git commit -m "fix: deep linking E2E adjustments (#536)" -``` - ---- - -## Self-Review - -**Spec coverage check:** -- ✅ P1: Nodes role tab → Task 2 -- ✅ P1: Packets time window → Task 3 -- ✅ P1: Packets region filter → Task 3 (depends on Task 1) -- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351) -- ✅ P1: Channels node panel → Task 4 -- ✅ P2+ items → explicitly out of scope per issue - -**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll). - -**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅ - -**Type consistency:** -- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅ -- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅ -- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅ diff --git a/docs/superpowers/specs/2026-04-23-scope-stats-design.md b/docs/superpowers/specs/2026-04-23-scope-stats-design.md deleted file mode 100644 index 22a310bd..00000000 --- a/docs/superpowers/specs/2026-04-23-scope-stats-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# Scope Stats Page — Design Spec - -**Issue**: Kpa-clawbot/CoreScope#899 -**Date**: 2026-04-23 -**Branch target**: `master` - ---- - -## Overview - -Add a dedicated **Scopes** page showing scope/region statistics for MeshCore transport-route packets. Scope filtering in MeshCore uses `TRANSPORT_FLOOD` (route_type 0) and `TRANSPORT_DIRECT` (route_type 3) packets that carry two 16-bit transport codes. Code1 ≠ `0000` means the packet is region-scoped. - -Feature 3 from the issue (default scope per client via advert) is **not implemented** — the advert format has no scope field in the current firmware. - ---- - -## How Scopes Work (Firmware) - -Transport code derivation (authoritative source: `meshcore-dev/MeshCore`): - -``` -key = SHA256("#regionname")[:16] // TransportKeyStore::getAutoKeyFor -Code1 = HMAC-SHA256(key, type || payload) // TransportKey::calcTransportCode, 2-byte output -``` - -Code1 is a **per-message** HMAC — the same region produces a different Code1 for every message. Identifying a region from Code1 requires knowing the region name in advance and recomputing the HMAC. - -`Code1 = 0000` is the "no scope" sentinel (also `FFFF` is reserved). Packets with route_type 1 or 2 (plain FLOOD/DIRECT) carry no transport codes. - ---- - -## Config - -Add `hashRegions` to the ingestor `Config` struct in `cmd/ingestor/config.go`, mirroring `hashChannels`: - -```json -"hashRegions": ["#belgium", "#eu", "#brussels"] -``` - -Normalization (same rules as `hashChannels`): -- Trim whitespace -- Prepend `#` if missing -- Skip empty entries - ---- - -## Ingestor Changes - -### Key derivation (`loadRegionKeys`) - -```go -func loadRegionKeys(cfg *Config) map[string][]byte { - // key = first 16 bytes of SHA256("#regionname") -} -``` - -Returns `map[string][]byte` (region name → 16-byte HMAC key). Called once at startup, stored on the `Store`. - -### Decoder: expose raw payload bytes - -Add `PayloadRaw []byte` to `DecodedPacket` in `cmd/ingestor/decoder.go`. Populated from the raw `buf` slice at the payload offset — zero-copy slice, no allocation. This is the **encrypted** payload bytes, matching what the firmware feeds into `calcTransportCode`. - -### At-ingest region matching - -In `BuildPacketData`: -- Skip if `route_type` not in `{0, 3}` → `scope_name` stays `nil` -- If `Code1 == "0000"` → `scope_name = nil` (unscoped transport, no scope involvement) -- If `Code1 != "0000"` → try each region key: - ``` - HMAC-SHA256(key, payloadType_byte || PayloadRaw) → first 2 bytes as uint16 - ``` - First match → `scope_name = "#regionname"`. No match → `scope_name = ""` (unknown scope). - -Add `ScopeName *string` to `PacketData`. - -### MQTT-sourced packets (DM / CHAN paths in main.go) - -These are injected directly without going through `BuildPacketData`. They use `route_type = 1` (FLOOD), so they are never transport-route packets. No scope matching needed for these paths. - ---- - -## Database - -### Migration - -```sql -ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL; -CREATE INDEX idx_tx_scope_name ON transmissions(scope_name) WHERE scope_name IS NOT NULL; -``` - -### Column semantics - -| Value | Meaning | -|-------|---------| -| `NULL` | Either: non-transport-route packet (route_type 1/2), or transport-route with Code1=0000 | -| `""` (empty string) | Transport-route, Code1 ≠ 0000, but no configured region matched | -| `"#belgium"` | Matched named region | - -The API stats queries resolve the NULL ambiguity by always filtering `route_type IN (0, 3)` first: -- `unscoped` count = `route_type IN (0,3) AND scope_name IS NULL` -- `scoped` count = `route_type IN (0,3) AND scope_name IS NOT NULL` - -### Backfill - -On migration, re-decode `raw_hex` for all rows where `route_type IN (0, 3)` and `scope_name IS NULL`. Run the same HMAC matching logic. Rows with `Code1 = 0000` remain `NULL`. - -The backfill runs in the existing migration framework in `cmd/ingestor/db.go`. If no regions are configured, backfill is skipped. - ---- - -## API - -### `GET /api/scope-stats` - -**Query param**: `window` — one of `1h`, `24h` (default), `7d` - -**Time-series bucket sizes**: -| Window | Bucket | -|--------|--------| -| `1h` | 5 min | -| `24h` | 1 hour | -| `7d` | 6 hours| - -**Response**: -```json -{ - "window": "24h", - "summary": { - "transportTotal": 1240, - "scoped": 890, - "unscoped": 350, - "unknownScope": 42 - }, - "byRegion": [ - { "name": "#belgium", "count": 612 }, - { "name": "#eu", "count": 236 } - ], - "timeSeries": [ - { "t": "2026-04-23T10:00:00Z", "scoped": 45, "unscoped": 18 }, - { "t": "2026-04-23T11:00:00Z", "scoped": 51, "unscoped": 22 } - ] -} -``` - -- `transportTotal` = `scoped + unscoped` (transport-route packets only) -- `scoped` = Code1 ≠ 0000 (named + unknown) -- `unscoped` = transport-route with Code1 = 0000 -- `unknownScope` = scoped but no region name matched (subset of `scoped`) -- `byRegion` sorted by count descending, excludes unknown -- `timeSeries` covers the full window at the bucket granularity - -Route: `GET /api/scope-stats` registered in `cmd/server/routes.go`. -No auth required (same as other read endpoints). -TTL cache: 30 seconds (heavier query than `/api/stats`). - ---- - -## Frontend - -### Navigation - -Add nav link between Channels and Nodes in `public/index.html`: -```html -Scopes -``` - -### `public/scopes.js` - -Three sections on the page: - -**1. Summary cards** (reuse existing card CSS pattern from home/analytics pages) -- Transport total, Scoped, Unscoped, Unknown scope -- Each card shows count + percentage of transport total - -**2. Per-region table** -Columns: Region, Messages, % of Scoped -Sorted by count descending. Last row: "Unknown scope" (italic) if unknownScope > 0. -Shows "No regions configured" message if `byRegion` is empty and `unknownScope = 0`. - -**3. Time-series chart** -- Window selector: `1h / 24h / 7d` (default 24h) -- Two lines: **Scoped** (blue) and **Unscoped** (grey) -- Uses the same lightweight canvas chart pattern as other pages (no external chart lib) - -### Cache buster - -`scopes.js` added to the `__BUST__` entries in `index.html` in the same commit. - ---- - -## Testing - -- Unit tests for `loadRegionKeys`: normalization, key bytes match firmware SHA256 derivation -- Unit tests for HMAC matching: known Code1 value computed from firmware logic, verified against Go implementation -- Integration test: ingest a synthetic transport-route packet with a known region, assert `scope_name` column is set correctly -- API test: `GET /api/scope-stats` returns correct summary counts against fixture DB - ---- - -## Out of Scope - -- Feature 3 (default scope per client via advert) — firmware has no advert scope field -- Drill-down from region row to filtered packet list (deferred) -- Private regions (`$`-prefixed) — use secret keys not publicly derivable 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..f17d7a62 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 are always visible. Saving requires entering the server's `apiKey`; on servers without one (or with a weak key), the save request returns `401`/`403` and the error is shown inline. -## 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 (`polygon`, `bufferKm`). Whether the `PUT` endpoint will accept a write depends on whether the server has an `apiKey` configured; clients should attempt the write and handle `401`/`403` if it isn't. + +``` +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 3e50d532..1e9ef4c9 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -878,6 +878,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) { @@ -1256,6 +1267,293 @@ '
'; } + 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) { + // Always expose edit controls. The server no longer leaks apiKey presence + // via writeEnabled (info-disclosure on a public endpoint); instead, the + // user enters their API key into the editor and writes either succeed or + // get a 401/403 surfaced as an inline error. + _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, 'No geo filter. Click the map to open the editor.'); + _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); @@ -1290,6 +1588,7 @@ _renderNodes() + _renderHome() + _renderDisplay() + + _renderGeoFilter() + _renderExport() + '
'; _bindEvents(container); @@ -1358,11 +1657,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 () { @@ -1645,7 +1948,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 1d970118..a34d0449 100644 --- a/public/geofilter-builder.html +++ b/public/geofilter-builder.html @@ -8,18 +8,18 @@ @@ -79,14 +79,14 @@

GeoFilter Builder

Save Draft preserves your polygon across sessions. Download exports a JSON snippet → 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 ↗