mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-23 06:22:28 +00:00
fix: deliver roadmap security and data-correctness foundation
This commit is contained in:
1 parent
fd8ed41797
commit
9fb228e7fe
16 files changed
+415
-154
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Generated
+39
-111
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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: '<details open onload="void 0" ontoggle="void 0">Fixture attribution</details><a href="https://example.invalid">Credits</a>',
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user