diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a690bf..baafe70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: cache-dependency-path: web/package-lock.json - working-directory: web run: npm ci + - name: Audit shipped frontend dependencies + working-directory: web + run: npm audit --omit=dev --audit-level=high - working-directory: web run: npm test - working-directory: web diff --git a/CHANGELOG.md b/CHANGELOG.md index 7537a77..87ae92c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.3.2 - 2026-09-09 + +- Add the paired audit roadmap and begin its security and data-correctness milestone. +- Upgrade to MapLibre 6.4.1, configure its bundled worker, and check shipped frontend dependencies in CI. The map now requires WebGL2; Netgraph retains Canvas2D. +- Omit ambiguous source legs even when a conflicting identity has no coordinates. Preserve independently confirmed downstream hops. +- Reject non-finite RF values and MQTT envelopes larger than 64 KiB before normalization. +- Reconcile connected browsers after capacity evictions using a coalesced reset and matching snapshot. +- Expire retained topology during quiet periods, avoid clean checkpoint rewrites, and retain failure/retry visibility. +- Add synthetic attribution-sanitizer, source-collision, RF, input-size, capacity, retention and checkpoint-retry regression coverage. + ## 0.3.1 - 2026-09-06 - Clear expired Live Follow cards and map highlights when the feed goes quiet, while keeping Follow ready for fresh activity. diff --git a/README.md b/README.md index 15e18f3..46181e2 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ The default bind is loopback-only. Keep it that way behind a TLS reverse proxy, ## MQTT input +MQTT bridge envelopes are limited to 64 KiB before normalization, and RF evidence must contain finite numbers. The geographic map requires WebGL2; Netgraph uses Canvas2D. + The subscriber defaults to `meshcore/#` and accepts the standard topic shape: ```text diff --git a/VERSION b/VERSION index 9e11b32..d15723f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.3.2 diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index 8a49118..2377a45 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -75,6 +75,7 @@ type Engine struct { prunedNodes atomic.Int64 prunedRoutes atomic.Int64 checkpointDirty bool + snapshotResetPending bool done chan struct{} publish func(Event) @@ -172,9 +173,7 @@ func (e *Engine) Run(ctx context.Context) { for { select { case <-ctx.Done(): - if dirtyCheckpoint { - _, _ = e.flushCheckpoint(time.Now()) - } + _, _ = e.flushCheckpoint(time.Now(), dirtyCheckpoint) e.updateSnapshot(time.Now()) return case message := <-e.input: @@ -201,13 +200,12 @@ func (e *Engine) Run(ctx context.Context) { dirtySnapshot = false } case now := <-checkpointTick.C: - if dirtyCheckpoint { - pruned, saved := e.flushCheckpointAndReset(now) - if pruned { - dirtySnapshot = false - } - dirtyCheckpoint = !saved + // Expiry is clock-driven even when no new MQTT state needs saving. + reset, saved := e.flushCheckpointAndReset(now, dirtyCheckpoint) + if reset { + dirtySnapshot = false } + dirtyCheckpoint = !saved } } } @@ -331,20 +329,20 @@ func (e *Engine) sourceNode(region string, packet meshcore.Packet) *privateNode return nil } matches := e.prefixes[prefixMapKey(region, 1, prefix)] - positioned := make([]*privateNode, 0, len(matches)) - for nodeKey := range matches { - if candidate := e.nodes[nodeKey]; candidate != nil && candidate.HasCoords { - positioned = append(positioned, candidate) - } - } - if len(positioned) != 1 { + // An unpositioned identity still makes a shared source hint ambiguous. + if len(matches) != 1 { return nil } - return positioned[0] + for nodeKey := range matches { + if candidate := e.nodes[nodeKey]; candidate != nil && candidate.HasCoords { + return candidate + } + } + return nil } func (e *Engine) resolveAndRecord(message mqtt.Message, packet meshcore.Packet, source, observer *privateNode, payloadKind string) ([]RouteSegmentV2, bool) { - if packet.InvalidForMap || (message.RSSI == nil && message.SNR == nil) { + if packet.InvalidForMap || (!finiteRF(message.RSSI) && !finiteRF(message.SNR)) { return nil, false } seen := make(map[string]struct{}, len(packet.Path)) @@ -476,6 +474,10 @@ func (e *Engine) emit(event Event) { } func (e *Engine) updateSnapshot(now time.Time) { + reset := e.snapshotResetPending + if reset { + e.seq.Add(1) + } nowMillis := now.UnixMilli() state := StateV2{ SchemaVersion: 2, @@ -523,6 +525,11 @@ func (e *Engine) updateSnapshot(now time.Time) { e.publicNodes.Store(int64(len(state.Nodes))) e.publicRoutes.Store(int64(len(state.Routes))) e.snapshot.Store(body) + if reset { + // Recovering clients must see the snapshot carrying this exact cursor. + e.snapshotResetPending = false + e.emit(Event{Name: "reset", Seq: state.Seq, Data: ResetEventV2{Seq: state.Seq, BootID: e.bootID}}) + } } func (e *Engine) publicStatus(now time.Time) PublicStatus { @@ -536,9 +543,14 @@ func (e *Engine) publicStatus(now time.Time) PublicStatus { return PublicStatus{Feed: feed, Activity: activity, LastPacketAt: e.lastPacket, Dropped: e.dropped.Load(), Version: e.version, GitSHA: e.gitSHA} } -func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { +func (e *Engine) flushCheckpoint(now time.Time, dirty bool) (bool, bool) { prunedRoutes, prunedNodes := e.pruneDurableState(now.UnixMilli()) pruned := prunedRoutes > 0 || prunedNodes > 0 + if !dirty && !pruned { + return false, true + } + e.prunedNodes.Add(int64(prunedNodes)) + e.prunedRoutes.Add(int64(prunedRoutes)) started := time.Now() if err := writeCheckpoint(e.checkpoint, e.nodes, e.routes); err != nil { e.checkpointOK.Store(false) @@ -556,8 +568,6 @@ func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { e.lastCheckpointAt.Store(now.UnixMilli()) e.lastCheckpointNodes.Store(int64(len(e.nodes))) e.lastCheckpointRoutes.Store(int64(len(e.routes))) - e.prunedNodes.Add(int64(prunedNodes)) - e.prunedRoutes.Add(int64(prunedRoutes)) e.log.Info("checkpoint saved", "bytes", bytes, "durationMs", duration.Milliseconds(), @@ -569,14 +579,13 @@ func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { return pruned, true } -func (e *Engine) flushCheckpointAndReset(now time.Time) (bool, bool) { - pruned, saved := e.flushCheckpoint(now) - if !pruned { +func (e *Engine) flushCheckpointAndReset(now time.Time, dirty bool) (bool, bool) { + pruned, saved := e.flushCheckpoint(now, dirty) + if !pruned && !e.snapshotResetPending { return false, saved } - seq := e.seq.Add(1) + e.snapshotResetPending = true e.updateSnapshot(now) - e.emit(Event{Name: "reset", Seq: seq, Data: ResetEventV2{Seq: seq, BootID: e.bootID}}) return true, saved } @@ -654,6 +663,7 @@ func (e *Engine) evictOldestNode() { return } id := nodePublicID(oldest) + e.snapshotResetPending = true delete(e.nodes, oldestKey) e.unindexNode(oldestKey, oldest) e.refreshNodeID(id) @@ -688,6 +698,7 @@ func (e *Engine) evictRoutes() { } } delete(e.routes, oldestID) + e.snapshotResetPending = true } } @@ -790,6 +801,10 @@ func isSensitiveHex(value string) bool { return false } +func finiteRF(value *float64) bool { + return value != nil && !math.IsNaN(*value) && !math.IsInf(*value, 0) +} + func validCoords(lat, lng float64) bool { return !math.IsNaN(lat) && !math.IsNaN(lng) && !math.IsInf(lat, 0) && !math.IsInf(lng, 0) && lat >= -85.051129 && lat <= 85.051129 && lng >= -180 && lng <= 180 && (lat != 0 || lng != 0) } diff --git a/backend/internal/engine/engine_test.go b/backend/internal/engine/engine_test.go index 2ca302a..77990ab 100644 --- a/backend/internal/engine/engine_test.go +++ b/backend/internal/engine/engine_test.go @@ -511,7 +511,7 @@ func TestCheckpointPruningResetsClientsAfterRefreshingSnapshot(t *testing.T) { } }) - pruned, saved := state.flushCheckpointAndReset(now) + pruned, saved := state.flushCheckpointAndReset(now, true) if !pruned || !saved { t.Fatalf("checkpoint pruned/saved = %v/%v, want true/true", pruned, saved) } diff --git a/backend/internal/engine/foundation_test.go b/backend/internal/engine/foundation_test.go new file mode 100644 index 0000000..608e0d2 --- /dev/null +++ b/backend/internal/engine/foundation_test.go @@ -0,0 +1,219 @@ +package engine + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "github.com/n30nex/cartolite-server/backend/internal/meshcore" + "github.com/n30nex/cartolite-server/backend/internal/mqtt" +) + +func TestUnpositionedSourceCollisionDoesNotCreateAnOriginLeg(t *testing.T) { + state := newTestEngine(t) + now := time.Now().UnixMilli() + source, _ := state.upsertNode("YKF", "DD112233", "Positioned", "companion", false, 43.35, -80.35, true, now) + state.upsertNode("YKF", "DD998877", "Unpositioned", "companion", false, 0, 0, false, now) + hop, _ := state.upsertNode("YKF", "AA112233", "Relay", "repeater", false, 43.4, -80.4, true, now) + observer, _ := state.upsertNode("YKF", "CC112233", "Observer", "unknown", true, 43.5, -80.5, true, now) + rssi := -75.0 + state.process(mqtt.Message{ + Topic: mqtt.Topic{Region: "YKF", PublisherKey: "CC112233", Kind: "packets"}, + ObserverKey: "CC112233", RSSI: &rssi, HeardAt: now, + RawHex: packetHexPayload(meshcore.PayloadPlainText, 1, []byte{0xaa}, 0, 0xdd), + }) + if len(state.routes) != 1 || state.routes[routePublicID(nodePublicID(hop), nodePublicID(observer))] == nil { + t.Fatal("independently resolved relay-to-observer hop was not preserved") + } + if state.routes[routePublicID(nodePublicID(source), nodePublicID(hop))] != nil { + t.Fatal("ambiguous source acquired a guessed origin leg") + } +} + +func TestResolverRequiresFiniteRFEvidence(t *testing.T) { + invalid, infinite, valid := math.NaN(), math.Inf(1), -75.0 + for _, test := range []struct { + name string + rssi *float64 + snr *float64 + want int + }{ + {"missing", nil, nil, 0}, + {"nan", &invalid, nil, 0}, + {"infinity", nil, &infinite, 0}, + {"both invalid", &invalid, &infinite, 0}, + {"RSSI only", &valid, nil, 1}, + {"finite SNR despite bad RSSI", &invalid, &valid, 1}, + } { + t.Run(test.name, func(t *testing.T) { + state := newTestEngine(t) + now := time.Now().UnixMilli() + state.upsertNode("YKF", "AA112233", "Relay", "repeater", false, 43.4, -80.4, true, now) + state.upsertNode("YKF", "CC112233", "Observer", "unknown", true, 43.5, -80.5, true, now) + state.process(mqtt.Message{ + Topic: mqtt.Topic{Region: "YKF", PublisherKey: "CC112233", Kind: "packets"}, + ObserverKey: "CC112233", RSSI: test.rssi, SNR: test.snr, HeardAt: now, + RawHex: packetHex(meshcore.PayloadControl, 1, 0xaa), + }) + if len(state.routes) != test.want { + t.Fatalf("got %d routes, want %d", len(state.routes), test.want) + } + }) + } +} + +// Observe the wire contract: the replacement snapshot must already be available +// when a client receives its reset cursor, with every route endpoint present. +func collectResetSnapshots(t *testing.T, state *Engine) *[]StateV2 { + t.Helper() + snapshots := []StateV2{} + state.SetPublisher(func(event Event) { + if event.Name != "reset" { + return + } + var snapshot StateV2 + if err := json.Unmarshal(state.StateJSON(), &snapshot); err != nil { + t.Fatal(err) + } + reset, ok := event.Data.(ResetEventV2) + if !ok || reset.Seq != event.Seq || snapshot.Seq != event.Seq || reset.BootID != snapshot.BootID { + t.Fatal("reset was published before its matching snapshot") + } + ids := make(map[string]bool, len(snapshot.Nodes)) + for _, node := range snapshot.Nodes { + ids[node.ID] = true + } + for _, route := range snapshot.Routes { + if !ids[route.FromID] || !ids[route.ToID] { + t.Fatal("reset snapshot has a dangling route") + } + } + snapshots = append(snapshots, snapshot) + }) + return &snapshots +} + +func TestNodeCapacityEvictionsCoalesceIntoOneAuthoritativeReset(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + for index := 0; index < maxNodes; index++ { + node := &privateNode{ + Region: "YKF", Key: fmt.Sprintf("%08X", index+1), Label: "Fixture", Role: "repeater", + HasCoords: true, Lat: 43.4, Lng: -80.4, LastSeen: now.Add(time.Duration(index) * time.Millisecond).UnixMilli(), + } + key := nodeMapKey(node.Region, node.Key) + state.nodes[key] = node + state.nodeIDs[nodePublicID(node)] = node + state.indexNode(key, node) + } + oldID := nodePublicID(state.nodes[nodeMapKey("YKF", "00000001")]) + nextID := nodePublicID(state.nodes[nodeMapKey("YKF", "00000002")]) + routeID := routePublicID(oldID, nextID) + state.routes[routeID] = &privateRoute{ID: routeID, FromID: oldID, ToID: nextID, PacketCount: 1, LastHeard: now.UnixMilli(), LastKind: "Text", Traffic: 1} + state.updateSnapshot(now) + resets := collectResetSnapshots(t, state) + for _, key := range []string{"FFFF0001", "FFFF0002"} { + state.upsertNode("YKF", key, "New fixture", "repeater", false, 43.5, -80.5, true, now.Add(time.Minute).UnixMilli()) + } + if len(*resets) != 0 { + t.Fatal("evictions reset clients per packet instead of on the snapshot clock") + } + state.updateSnapshot(now.Add(time.Minute)) + if len(*resets) != 1 || len((*resets)[0].Nodes) != maxNodes || len((*resets)[0].Routes) != 0 { + t.Fatal("node-capacity reset did not provide bounded current topology") + } + for _, node := range (*resets)[0].Nodes { + if node.ID == oldID || node.ID == nextID { + t.Fatal("evicted identity survived in replacement snapshot") + } + } + state.updateSnapshot(now.Add(2 * time.Minute)) + if len(*resets) != 1 { + t.Fatal("unchanged topology emitted another reset") + } +} + +func TestRouteCapacityEvictionsResetClientsBeforeAnyExpiry(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + nodes := make([]*privateNode, 202) + for index := range nodes { + nodes[index], _ = state.upsertNode("YKF", fmt.Sprintf("%08X", index+1), "Fixture", "repeater", false, 43.4, -80.4, true, now.UnixMilli()) + } + oldest := []string{} + for from := 0; from < len(nodes) && len(state.routes) < maxRoutes+2; from++ { + for to := from + 1; to < len(nodes) && len(state.routes) < maxRoutes+2; to++ { + fromID, toID := nodePublicID(nodes[from]), nodePublicID(nodes[to]) + id := routePublicID(fromID, toID) + if len(oldest) < 2 { + oldest = append(oldest, id) + } + state.routes[id] = &privateRoute{ + ID: id, FromID: fromID, ToID: toID, PacketCount: 1, LastKind: "Text", Traffic: 1, + LastHeard: now.Add(time.Duration(len(state.routes)) * time.Millisecond).UnixMilli(), + } + } + } + state.updateSnapshot(now) + resets := collectResetSnapshots(t, state) + state.evictRoutes() + state.updateSnapshot(now.Add(time.Minute)) + if len(*resets) != 1 || len((*resets)[0].Routes) != maxRoutes { + t.Fatal("route-capacity reset did not provide the bounded snapshot") + } + for _, id := range oldest { + if state.routes[id] != nil { + t.Fatal("oldest route was not evicted") + } + } +} + +func TestQuietMaintenanceExpiresStateWithoutRepeatedWrites(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + first, _ := state.upsertNode("YKF", "AA112233", "Quiet first", "repeater", false, 43.4, -80.4, true, now.Add(-nodeRetentionWindow+time.Second).UnixMilli()) + second, _ := state.upsertNode("YKF", "BB112233", "Quiet second", "repeater", false, 43.5, -80.5, true, now.Add(-nodeRetentionWindow+time.Second).UnixMilli()) + fromID, toID := nodePublicID(first), nodePublicID(second) + id := routePublicID(fromID, toID) + state.routes[id] = &privateRoute{ID: id, FromID: fromID, ToID: toID, PacketCount: 1, LastKind: "Text", Traffic: 1, LastHeard: now.Add(-routeVisibilityWindow + time.Second).UnixMilli()} + if _, saved := state.flushCheckpointAndReset(now, true); !saved { + t.Fatal("could not establish clean checkpoint") + } + resets := collectResetSnapshots(t, state) + later := now.Add(2 * time.Second) + if reset, saved := state.flushCheckpointAndReset(later, false); !reset || !saved { + t.Fatal("quiet maintenance skipped expired state") + } + nodes, routes, err := loadCheckpoint(state.checkpoint) + if err != nil || len(nodes) != 0 || len(routes) != 0 || len(*resets) != 1 { + t.Fatal("expired topology survived checkpoint or client reconciliation") + } + lastSave := state.OperationalStats().LastCheckpointAt + if reset, saved := state.flushCheckpointAndReset(later.Add(checkpointInterval), false); reset || !saved { + t.Fatal("unchanged quiet maintenance did not remain idle") + } + if state.OperationalStats().LastCheckpointAt != lastSave { + t.Fatal("clean quiet state caused an unnecessary checkpoint write") + } +} + +func TestFailedExpiryCheckpointStillResetsAndCanRetry(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + state.upsertNode("YKF", "AA112233", "Expired", "repeater", false, 43.4, -80.4, true, now.Add(-nodeRetentionWindow-time.Second).UnixMilli()) + path := state.checkpoint + state.checkpoint = t.TempDir() // A directory cannot be replaced by the checkpoint file. + resets := collectResetSnapshots(t, state) + if reset, saved := state.flushCheckpointAndReset(now, false); !reset || saved || state.CheckpointHealthy() { + t.Fatal("failed expiry save hid the topology reset or checkpoint failure") + } + state.checkpoint = path + if reset, saved := state.flushCheckpointAndReset(now.Add(checkpointInterval), true); reset || !saved || !state.CheckpointHealthy() { + t.Fatal("failed checkpoint could not be retried without a duplicate reset") + } + if len(*resets) != 1 || state.OperationalStats().PrunedCheckpointNodes != 1 { + t.Fatal("pruning/reset was lost or counted twice across checkpoint retry") + } +} diff --git a/backend/internal/meshcore/decode.go b/backend/internal/meshcore/decode.go index e04e6b4..f56eb1a 100644 --- a/backend/internal/meshcore/decode.go +++ b/backend/internal/meshcore/decode.go @@ -160,7 +160,7 @@ func SourcePublicKey(packet Packet) string { } // SourcePrefix returns the one-byte source hint present in these normal packet -// payloads. Callers must require a unique positioned node before using it. +// payloads. Callers must establish unique identity before requiring coordinates. func SourcePrefix(packet Packet) (string, bool) { switch packet.PayloadType { case PayloadRequest, PayloadResponse, PayloadPlainText, PayloadPath: diff --git a/backend/internal/mqtt/message.go b/backend/internal/mqtt/message.go index ad5edd5..de7994a 100644 --- a/backend/internal/mqtt/message.go +++ b/backend/internal/mqtt/message.go @@ -4,12 +4,16 @@ import ( "bytes" "encoding/json" "fmt" + "math" "regexp" "strconv" "strings" "time" ) +// Bound bridge-envelope parsing and queued state; transport limits belong to the broker. +const maxMessageBytes = 64 * 1024 + var ( regionPattern = regexp.MustCompile(`^[A-Z0-9_-]{1,16}$`) keyPattern = regexp.MustCompile(`^[0-9A-F]{8,128}$`) @@ -53,6 +57,9 @@ func ParseTopic(value string) (Topic, error) { } func Normalize(topic string, body []byte, receivedAt time.Time) (Message, error) { + if len(body) > maxMessageBytes { + return Message{}, fmt.Errorf("mqtt payload exceeds %d bytes", maxMessageBytes) + } info, err := ParseTopic(topic) if err != nil { return Message{}, err @@ -140,16 +147,21 @@ func firstString(values map[string]any, keys ...string) string { func firstNumber(values map[string]any, keys ...string) *float64 { for _, key := range keys { - if value, ok := values[key]; ok { - switch typed := value.(type) { - case float64: - copy := typed - return © - case string: - if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil { - return &parsed - } + var number float64 + switch typed := values[key].(type) { + case float64: + number = typed + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + continue } + number = parsed + default: + continue + } + if !math.IsNaN(number) && !math.IsInf(number, 0) { + return &number } } return nil diff --git a/backend/internal/mqtt/validation_test.go b/backend/internal/mqtt/validation_test.go new file mode 100644 index 0000000..b768b42 --- /dev/null +++ b/backend/internal/mqtt/validation_test.go @@ -0,0 +1,45 @@ +package mqtt + +import ( + "math" + "strings" + "testing" + "time" +) + +func TestNonFiniteNumbersDoNotBecomeRFOrCoordinates(t *testing.T) { + for _, invalid := range []any{"NaN", "+Inf", "-Inf", "Infinity", math.NaN(), math.Inf(1), math.Inf(-1)} { + if firstNumber(map[string]any{"rssi": invalid}, "rssi") != nil { + t.Fatalf("accepted non-finite numeric input %v", invalid) + } + if _, _, ok := Coordinates(map[string]any{"lat": invalid, "lng": -80.0}); ok { + t.Fatal("accepted a non-finite coordinate") + } + } + value := firstNumber(map[string]any{"RSSI": "NaN", "rssi": "-75"}, "RSSI", "rssi") + if value == nil || *value != -75 { + t.Fatal("a finite fallback field was ignored") + } + message, err := Normalize("meshcore/YKF/AA112233/packets", []byte(`{"raw":"0901AA00AA","rssi":"NaN","snr":"7.4"}`), time.Now()) + if err != nil || message.RSSI != nil || message.SNR == nil || *message.SNR != 7.4 { + t.Fatal("normalization did not retain only finite RF evidence") + } +} + +func TestMQTTEnvelopeSizeIsBoundedBeforeNormalization(t *testing.T) { + topic := "meshcore/YKF/AA112233/packets" + for _, oversized := range []string{ + strings.Repeat("AA", maxMessageBytes/2+1), + `{"raw":"` + strings.Repeat("AA", maxMessageBytes/2) + `"}`, + } { + if _, err := Normalize(topic, []byte(oversized), time.Now()); err == nil { + t.Fatal("oversized MQTT payload reached normalization") + } + } + valid := `{"raw":"0901AA00AA","rssi":-75}` + body := valid + strings.Repeat(" ", maxMessageBytes-len(valid)) + message, err := Normalize(topic, []byte(body), time.Now()) + if err != nil || message.RawHex != "0901AA00AA" || message.RSSI == nil { + t.Fatal("valid envelope at the byte limit was rejected") + } +} diff --git a/web/package-lock.json b/web/package-lock.json index fdf0cd8..e079349 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,14 +1,14 @@ { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { - "maplibre-gl": "5.19.0" + "maplibre-gl": "6.4.1" }, "devDependencies": { "@playwright/test": "1.61.1", @@ -192,19 +192,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", - "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "license": "ISC", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", @@ -227,44 +214,38 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", - "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", - "pbf": "^4.0.2" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" + "pbf": "^5.0.0" } }, "node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", - "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", - "license": "ISC" - }, - "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", - "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", "license": "ISC", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz", + "integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", @@ -277,12 +258,6 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", - "license": "BSD-2-Clause" - }, "node_modules/@maplibre/mlt": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", @@ -303,18 +278,6 @@ "pbf": "^5.1.0" } }, - "node_modules/@maplibre/vt-pbf/node_modules/pbf": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", - "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -673,15 +636,6 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1155,18 +1109,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", @@ -1621,32 +1563,27 @@ } }, "node_modules/maplibre-gl": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", - "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz", + "integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.0.7", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^5.0.4", - "@maplibre/maplibre-gl-style-spec": "^24.4.1", - "@maplibre/mlt": "^1.1.6", - "@maplibre/vt-pbf": "^4.2.1", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", - "earcut": "^3.0.2", + "earcut": "^3.2.3", "gl-matrix": "^3.4.4", - "kdbush": "^4.0.2", + "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", + "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", - "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" }, "engines": { @@ -1773,9 +1710,9 @@ "license": "MIT" }, "node_modules/pbf": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", - "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", "license": "BSD-3-Clause", "dependencies": { "resolve-protobuf-schema": "^2.1.0" @@ -1994,15 +1931,6 @@ "dev": true, "license": "MIT" }, - "node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/web/package.json b/web/package.json index 3988296..c2f7528 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "private": true, "type": "module", "scripts": { @@ -11,7 +11,7 @@ "test:watch": "vitest" }, "dependencies": { - "maplibre-gl": "5.19.0" + "maplibre-gl": "6.4.1" }, "devDependencies": { "@playwright/test": "1.61.1", diff --git a/web/src/attribution.test.ts b/web/src/attribution.test.ts new file mode 100644 index 0000000..dbabc14 --- /dev/null +++ b/web/src/attribution.test.ts @@ -0,0 +1,23 @@ +import { AttributionControl, type Map as MapLibreMap } from 'maplibre-gl'; +import { expect, it } from 'vitest'; + +it('removes consecutive unsafe attribution attributes while keeping safe credit text and links', () => { + // Exercise the dependency's public control API without requiring a GPU in the unit job. + const map = { + style: { tileManagers: {} }, + _getUIString: (key: string) => key, + getCanvasContainer: () => document.createElement('div'), + on() {}, + off() {}, + } as unknown as MapLibreMap; + const control = new AttributionControl({ + compact: false, + customAttribution: '
Fixture attribution
Credits', + }); + const element = control.onAdd(map); + expect(element.textContent).toContain('Fixture attribution'); + expect(element.querySelector('[onload], [ontoggle]')).toBeNull(); + expect(element.querySelector('a')?.getAttribute('href')).toBe('https://example.invalid'); + control.onRemove(); +}); + diff --git a/web/src/map.ts b/web/src/map.ts index ec4de83..993b84c 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -1,4 +1,6 @@ -import maplibregl, { +import * as maplibregl from 'maplibre-gl'; +import workerURL from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; +import { type ExpressionSpecification, type GeoJSONSource, type GeoJSONSourceDiff, @@ -36,6 +38,8 @@ import { } from './trafficVisuals'; import type { EndpointV2, NodeV2, PacketView, RouteV2, StateV2 } from './types'; +maplibregl.setWorkerUrl(workerURL); + export const DEFAULT_CENTER: [number, number] = [0, 20]; export const DEFAULT_ZOOM = 1.4; export const DETAIL_ZOOM = 8.4; diff --git a/web/src/packetAnimator.test.ts b/web/src/packetAnimator.test.ts index c77aaa0..b7a7950 100644 --- a/web/src/packetAnimator.test.ts +++ b/web/src/packetAnimator.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type maplibregl from 'maplibre-gl'; +import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, RoutePacketView, RouteSegmentView } from './types'; import { PACKET_KIND_COLORS } from './trafficVisuals'; import { diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index bc425be..9102787 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -1,5 +1,5 @@ import { stableHash as stableVisualHash, colorWithAlpha as withAlpha } from './trafficVisuals'; -import type maplibregl from 'maplibre-gl'; +import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, ObserverPacketEventV2, PacketView, RoutePacketView, RouteSegmentView } from './types'; import { TerrainProjector, surfaceArc, surfacePathPoint, surfaceTrail, traceSurfacePath, type SurfacePoint } from './terrainProjection'; import {