fix(map): reject impossible advert coordinates before they blank the map

A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.

Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.

Four layers, so no single miss can blank a map again:

- app/geo.py: one definition of a usable coordinate, applied on write in
  Database.upsert_contact() so the cache stops storing garbage, and on API
  output so a cache poisoned by an older build heals itself with no
  migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
  are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
  replace eight copies of the same check across six files. Path Analyzer
  had a looser variant that also accepted lon == 0, which put nodes off
  west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
  slips through costs its own pin rather than the whole map.

Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-08-28 11:35:53 +02:00
1 parent ad17791c3a
commit 8f1334ee00
19 files changed
+495 -90

No files matched your search

+23 -3
View File
@@ -13,6 +13,8 @@ from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from app.geo import is_valid_contact_type, sanitize_contact_latlon
logger = logging.getLogger(__name__)
SCHEMA_FILE = Path(__file__).parent / 'schema.sql'
@@ -123,16 +125,34 @@ class Database:
def upsert_contact(self, public_key: str, name: str = '', **kwargs) -> None:
public_key = public_key.lower()
# Adverts are unvalidated mesh input: a corrupted packet can carry a
# latitude of 1642 or a type of 210. Coordinates are dropped rather
# than stored, because one out-of-range value blows up map bounds for
# every other contact. A bogus type is only logged - it already
# renders as UNKNOWN and is filtered out of the maps, and keeping the
# raw value preserves the evidence that the advert was corrupted.
contact_type = kwargs.get('type', 0)
if not is_valid_contact_type(contact_type):
logger.warning(
f"Corrupted advert type for {name or public_key[:12]} "
f"({public_key[:8]}...): type={contact_type!r}"
)
adv_lat, adv_lon = sanitize_contact_latlon(
kwargs.get('adv_lat'), kwargs.get('adv_lon'),
label=f"{name or public_key[:12]} ({public_key[:8]}...)"
)
fields = {
'public_key': public_key,
'name': name,
'type': kwargs.get('type', 0),
'type': contact_type,
'flags': kwargs.get('flags', 0),
'out_path': kwargs.get('out_path', ''),
'out_path_len': kwargs.get('out_path_len', 0),
'last_advert': kwargs.get('last_advert'),
'adv_lat': kwargs.get('adv_lat'),
'adv_lon': kwargs.get('adv_lon'),
'adv_lat': adv_lat,
'adv_lon': adv_lon,
'source': kwargs.get('source', 'advert'),
'is_protected': kwargs.get('is_protected', 0),
}
+90
View File
@@ -0,0 +1,90 @@
"""
Coordinate sanity checks for advert-sourced positions.
Adverts arrive from the mesh unvalidated, and a corrupted packet can carry a
latitude of 1642 or a longitude of -1768. A single such row poisons every map
that includes it: Leaflet clamps latitude when projecting but not longitude,
so fitBounds() zooms out to the whole world and pushes every real marker
off-screen. Reported by a tester on 2026-08-28 - the cached-contacts map was
blank because two of 2131 cached contacts carried garbage coordinates.
Coordinates are therefore checked twice: on write, so the cache never stores
them again, and on API output, so a cache already poisoned by an older build
heals itself without a migration.
The JavaScript counterpart is static/js/geo-utils.js - keep the two in sync.
"""
import math
import logging
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
MAX_LAT = 90.0
MAX_LON = 180.0
# MeshCore advert types: 0=none, 1=chat/companion, 2=repeater, 3=room, 4=sensor.
# Anything else means the advert was corrupted in flight.
VALID_CONTACT_TYPES = frozenset((0, 1, 2, 3, 4))
def _coord(value: Any, limit: float) -> Optional[float]:
"""Return value as a float inside +/-limit, or None if it is unusable."""
if value is None:
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(num) or abs(num) > limit:
return None
return num
def sanitize_latlon(lat: Any, lon: Any) -> Tuple[Optional[float], Optional[float]]:
"""
Return (lat, lon) as floats, or (None, None) if either value is unusable.
Both are dropped together on purpose: half a position is not a position,
and leaving one component behind would still skew map bounds.
"""
clean_lat = _coord(lat, MAX_LAT)
clean_lon = _coord(lon, MAX_LON)
if clean_lat is None or clean_lon is None:
return None, None
return clean_lat, clean_lon
def sanitize_contact_latlon(lat: Any, lon: Any, label: str = '') -> Tuple[Optional[float], Optional[float]]:
"""sanitize_latlon() that logs whatever it had to throw away."""
clean_lat, clean_lon = sanitize_latlon(lat, lon)
if clean_lat is None and (lat is not None or lon is not None):
logger.warning(
f"Dropping out-of-range advert coordinates for {label or 'contact'}: "
f"lat={lat!r} lon={lon!r}"
)
return clean_lat, clean_lon
def is_valid_contact_type(value: Any) -> bool:
"""True for a MeshCore advert type we know how to render."""
return isinstance(value, int) and not isinstance(value, bool) and value in VALID_CONTACT_TYPES
def scrub_geo(payload: Dict, lat_key: str = 'adv_lat', lon_key: str = 'adv_lon') -> Dict:
"""
Null out unusable coordinates in an outgoing API payload (in place).
Applied to every response that feeds a map, so a contact cached by an
older build cannot blank the map it appears on. Keys the payload does not
already carry are left alone.
"""
if lat_key not in payload and lon_key not in payload:
return payload
lat, lon = sanitize_latlon(payload.get(lat_key), payload.get(lon_key))
if lat_key in payload:
payload[lat_key] = lat
if lon_key in payload:
payload[lon_key] = lon
return payload
+24 -15
View File
@@ -24,6 +24,7 @@ from app.config import config, runtime_config
from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager
from app.contacts_cache import get_all_names, get_all_contacts
from app.geo import sanitize_latlon, scrub_geo
from app import demo_guard
logger = logging.getLogger(__name__)
@@ -1084,6 +1085,10 @@ def get_cached_contacts():
except (ValueError, TypeError):
last_advert_ts = 0
# Scrub on output too: a cache poisoned by an older
# build must not be able to blank the map it feeds
adv_lat, adv_lon = sanitize_latlon(c.get('adv_lat'), c.get('adv_lon'))
contacts.append({
'public_key': pk,
'public_key_prefix': pk[:12],
@@ -1092,8 +1097,8 @@ def get_cached_contacts():
'last_seen': c.get('last_seen', ''),
'last_advert': last_advert_ts,
'source': c.get('source', ''),
'adv_lat': c.get('adv_lat'),
'adv_lon': c.get('adv_lon'),
'adv_lat': adv_lat,
'adv_lon': adv_lon,
'type_label': {0: 'COM', 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}.get(c.get('type', 1), 'UNKNOWN'),
'is_ignored': pk in ignored_keys,
'is_blocked': pk in blocked_keys,
@@ -1273,6 +1278,7 @@ def preview_cleanup_contacts():
contacts = []
for public_key, details in contacts_detailed.items():
out_path_len = details.get('out_path_len', -1)
adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
contacts.append({
'public_key': public_key,
'name': details.get('adv_name', ''),
@@ -1283,8 +1289,8 @@ def preview_cleanup_contacts():
'out_path_len': out_path_len,
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon')
'adv_lat': adv_lat,
'adv_lon': adv_lon
})
# Filter contacts
@@ -1446,9 +1452,10 @@ def get_device_info():
info = dm.get_device_info()
if info:
# Own position feeds the star marker on the contacts map
return jsonify({
'success': True,
'info': info,
'info': scrub_geo(dict(info)),
}), 200
else:
return jsonify({'success': False, 'error': 'No device info available'}), 503
@@ -3318,6 +3325,9 @@ def get_contacts_detailed_api():
out_path_raw = details.get('out_path', '')
out_path_hash_mode = details.get('out_path_hash_mode', 0)
path_or_mode = _format_path_display(out_path_len, out_path_raw, out_path_hash_mode)
# Out-of-range coordinates are dropped, not forwarded: one bad
# value makes Leaflet fit the map to the whole world (see app.geo)
adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
contact = {
# All original fields from contact_info
@@ -3328,8 +3338,8 @@ def get_contacts_detailed_api():
'out_path': out_path_raw,
'out_path_hash_mode': out_path_hash_mode,
'last_advert': details.get('last_advert'),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
'adv_lat': adv_lat,
'adv_lon': adv_lon,
'lastmod': details.get('lastmod'),
# Computed/convenience fields
'name': details.get('adv_name', ''), # Map adv_name to name for compatibility
@@ -6209,6 +6219,7 @@ def _merged_repeater_entry(row, device_by_key):
'last_advert': None,
}
if details:
adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
entry.update({
'name': details.get('adv_name', ''),
'out_path_len': details.get('out_path_len', -1),
@@ -6218,8 +6229,8 @@ def _merged_repeater_entry(row, device_by_key):
details.get('out_path_len', -1),
details.get('out_path', ''),
details.get('out_path_hash_mode', 0)),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
'adv_lat': adv_lat,
'adv_lon': adv_lon,
'last_advert': details.get('last_advert'),
})
return entry
@@ -6692,18 +6703,16 @@ def repeater_neighbours(public_key):
contact = dm.mc.get_contact_by_key_prefix(prefix) if dm.mc else None
if contact:
entry['name'] = contact.get('adv_name', '') or ''
lat = contact.get('adv_lat')
lon = contact.get('adv_lon')
if lat and lon and (lat != 0 or lon != 0):
lat, lon = sanitize_latlon(contact.get('adv_lat'), contact.get('adv_lon'))
if lat and lon:
entry['lat'] = lat
entry['lon'] = lon
elif db:
db_contact = db.get_contact_by_prefix(prefix)
if db_contact:
entry['name'] = db_contact.get('name', '') or ''
lat = db_contact.get('adv_lat')
lon = db_contact.get('adv_lon')
if lat and lon and (lat != 0 or lon != 0):
lat, lon = sanitize_latlon(db_contact.get('adv_lat'), db_contact.get('adv_lon'))
if lat and lon:
entry['lat'] = lat
entry['lon'] = lon
entries.append(entry)
+46 -42
View File
@@ -194,7 +194,7 @@ function updateMapMarkers() {
const bounds = [];
// Add own device marker (star shape, distinct from contacts)
if (_selfInfo && _selfInfo.adv_lat && _selfInfo.adv_lon && (_selfInfo.adv_lat !== 0 || _selfInfo.adv_lon !== 0)) {
if (hasValidGps(_selfInfo)) {
const ownIcon = L.divIcon({
html: '<i class="bi bi-star-fill" style="color: #dc3545; font-size: 20px; text-shadow: 0 0 3px #fff;"></i>',
iconSize: [20, 20],
@@ -207,48 +207,56 @@ function updateMapMarkers() {
bounds.push([_selfInfo.adv_lat, _selfInfo.adv_lon]);
}
// Each marker is added on its own: a contact we still failed to reject
// must cost its own pin, never the whole map
filteredContacts.forEach(c => {
const color = CONTACT_TYPE_COLORS[c.type] || '#2196F3';
const typeName = CONTACT_TYPE_NAMES[c.type] || t('common.unknown');
const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
try {
const color = CONTACT_TYPE_COLORS[c.type] || '#2196F3';
const typeName = CONTACT_TYPE_NAMES[c.type] || t('common.unknown');
const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
L.circleMarker([c.adv_lat, c.adv_lon], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
})
.addTo(markersGroup)
.bindPopup(`<b>${c.name}</b><br><span class="text-muted">${typeName}</span>${lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : ''}`);
L.circleMarker([c.adv_lat, c.adv_lon], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
})
.addTo(markersGroup)
.bindPopup(`<b>${c.name}</b><br><span class="text-muted">${typeName}</span>${lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : ''}`);
bounds.push([c.adv_lat, c.adv_lon]);
bounds.push([c.adv_lat, c.adv_lon]);
} catch (err) {
console.warn('Skipping contact marker:', c.name, err);
}
});
cachedFiltered.forEach(c => {
const typeNum = TYPE_LABEL_TO_NUM[c.type_label] || 1;
const color = CONTACT_TYPE_COLORS[typeNum] || '#2196F3';
const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
try {
const typeNum = TYPE_LABEL_TO_NUM[c.type_label] || 1;
const color = CONTACT_TYPE_COLORS[typeNum] || '#2196F3';
const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
L.circleMarker([c.adv_lat, c.adv_lon], {
radius: 8,
fillColor: color,
color: '#999',
weight: 1,
opacity: 0.8,
fillOpacity: 0.5
})
.addTo(markersGroup)
.bindPopup(`<b>${c.name}</b><br><span class="text-muted">${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}</span>${lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : ''}`);
L.circleMarker([c.adv_lat, c.adv_lon], {
radius: 8,
fillColor: color,
color: '#999',
weight: 1,
opacity: 0.8,
fillOpacity: 0.5
})
.addTo(markersGroup)
.bindPopup(`<b>${c.name}</b><br><span class="text-muted">${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}</span>${lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : ''}`);
bounds.push([c.adv_lat, c.adv_lon]);
bounds.push([c.adv_lat, c.adv_lon]);
} catch (err) {
console.warn('Skipping cached contact marker:', c.name, err);
}
});
if (bounds.length === 1) {
leafletMap.setView(bounds[0], 13);
} else if (bounds.length > 1) {
leafletMap.fitBounds(bounds, { padding: [20, 20] });
if (!fitMapToPoints(leafletMap, bounds)) {
leafletMap.setView([52.0, 19.0], 6);
}
}
@@ -285,15 +293,11 @@ async function showAllContactsOnMap() {
}
if (deviceData.success && deviceData.contacts) {
allContactsWithGps = deviceData.contacts.filter(c =>
c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)
);
allContactsWithGps = withValidGps(deviceData.contacts);
}
if (cachedData.success && cachedData.contacts) {
allCachedContactsWithGps = cachedData.contacts.filter(c =>
c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)
);
allCachedContactsWithGps = withValidGps(cachedData.contacts);
}
updateMapMarkers();
@@ -359,7 +363,7 @@ async function loadContactsGeoCache() {
// Process device contacts
if (detailedData.success && detailedData.contacts) {
detailedData.contacts.forEach(c => {
if (c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
if (hasValidGps(c)) {
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
}
if (c.name && c.public_key) {
@@ -376,7 +380,7 @@ async function loadContactsGeoCache() {
// Process cached contacts (fills gaps for contacts not on device)
if (cachedData.success && cachedData.contacts) {
cachedData.contacts.forEach(c => {
if (!contactsGeoCache[c.name] && c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
if (!contactsGeoCache[c.name] && hasValidGps(c)) {
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
}
if (c.name && c.public_key && !contactsPubkeyMap[c.name]) {
@@ -2265,7 +2269,7 @@ async function loadDeviceInfo() {
const shortKey = pubKey.length > 12 ? `${pubKey.slice(0, 6)}...${pubKey.slice(-6)}` : pubKey;
// Location
const hasLocation = info.adv_lat && info.adv_lon && (info.adv_lat !== 0 || info.adv_lon !== 0);
const hasLocation = hasValidGps(info);
const coords = hasLocation ? `${info.adv_lat.toFixed(6)}, ${info.adv_lon.toFixed(6)}` : tHtml('device.info.location_none');
// Build table rows
+2 -2
View File
@@ -1352,7 +1352,7 @@ function createContactCard(contact, index) {
actionsDiv.appendChild(approveBtn);
// Map button (only if GPS coordinates available)
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
if (hasValidGps(contact)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = `<i class="bi bi-geo-alt"></i> <span class="btn-label">${tHtml('contacts.map.title')}</span>`;
@@ -2210,7 +2210,7 @@ function createExistingContactCard(contact, index) {
actionsDiv.className = 'd-flex gap-2 mt-2';
// Map button - for ANY contact with GPS coordinates
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
if (hasValidGps(contact)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = `<i class="bi bi-geo-alt"></i> <span class="btn-label">${tHtml('contacts.map.title')}</span>`;
+4 -8
View File
@@ -1286,7 +1286,7 @@ function populateContactInfoModal() {
}
// GPS
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
if (hasValidGps(contact)) {
const div = document.createElement('div');
div.className = 'small mb-2';
div.innerHTML = `<i class="bi bi-geo-alt"></i> ${contact.adv_lat.toFixed(4)}, ${contact.adv_lon.toFixed(4)}`;
@@ -2977,10 +2977,8 @@ async function loadRepeaterMapMarkers() {
}
}
// Filter: only those with GPS
let repeaters = (_repeatersCache || []).filter(r =>
r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0)
);
// Filter: only those with a usable GPS position
let repeaters = withValidGps(_repeatersCache);
if (!showCached) {
// Non-cached: only repeaters that are on the device (have recent advert)
@@ -3032,9 +3030,7 @@ async function loadRepeaterMapMarkers() {
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
if (bounds.length > 0) {
_rptMap.fitBounds(bounds, { padding: [20, 20] });
}
fitMapToPoints(_rptMap, bounds);
}
/**
+112
View File
@@ -0,0 +1,112 @@
/**
* Geographic Coordinate Utilities
*
* Advert positions arrive from the mesh unvalidated, and a corrupted packet
* can carry a latitude of 1642 or a longitude of -1768. A single such entry
* poisons a whole map: Leaflet clamps latitude when projecting but not
* longitude, so fitBounds() zooms out to the entire world and pushes every
* real marker off-screen.
*
* The server drops such values on write and on API output (see app/geo.py);
* this is the last line of defence, and the single place where "does this
* contact have a usable position?" is answered for every map in the app.
*/
const GEO_MAX_LAT = 90;
const GEO_MAX_LON = 180;
/**
* True when value is a finite number within +/-limit degrees.
* @param {*} value
* @param {number} limit
* @returns {boolean}
*/
function isValidCoord(value, limit) {
if (value === null || value === undefined || value === '') return false;
const num = Number(value);
return Number.isFinite(num) && Math.abs(num) <= limit;
}
/**
* True when an object carries a usable advert position.
*
* Rejects missing values, non-finite values, coordinates outside the
* geographic range, and the zero placeholder. A zero in either component
* means "no fix" - firmware sends 0/0 for a node without GPS, and a
* genuine node sitting exactly on the equator or the prime meridian to six
* decimal places does not happen in practice.
*
* @param {Object} obj - contact / repeater / neighbour entry
* @param {string} [latKey='adv_lat']
* @param {string} [lonKey='adv_lon']
* @returns {boolean}
*/
function hasValidGps(obj, latKey, lonKey) {
if (!obj) return false;
const lat = obj[latKey || 'adv_lat'];
const lon = obj[lonKey || 'adv_lon'];
if (!isValidCoord(lat, GEO_MAX_LAT) || !isValidCoord(lon, GEO_MAX_LON)) return false;
return Number(lat) !== 0 && Number(lon) !== 0;
}
/**
* True when a [lat, lon] pair is safe to hand to Leaflet.
* @param {Array} point
* @returns {boolean}
*/
function isValidLatLng(point) {
return Array.isArray(point) && point.length >= 2 &&
isValidCoord(point[0], GEO_MAX_LAT) && isValidCoord(point[1], GEO_MAX_LON);
}
/**
* Filter a list down to entries with a usable advert position.
* @param {Array} list
* @param {string} [latKey]
* @param {string} [lonKey]
* @returns {Array}
*/
function withValidGps(list, latKey, lonKey) {
return (list || []).filter(item => hasValidGps(item, latKey, lonKey));
}
/**
* Fit a Leaflet map to a list of [lat, lon] points, skipping any that would
* distort the view. Returns false when nothing could be fitted, so callers
* can fall back to their own default view.
*
* @param {L.Map} map
* @param {Array<Array<number>>} points
* @param {Object} [options] - padding, maxZoom, singleZoom
* @returns {boolean}
*/
function fitMapToPoints(map, points, options) {
const opts = options || {};
if (!map) return false;
const valid = (points || []).filter(isValidLatLng);
if (valid.length === 0) return false;
if (valid.length === 1) {
map.setView(valid[0], opts.singleZoom || 13);
return true;
}
const bounds = L.latLngBounds(valid);
if (!bounds.isValid()) return false;
map.fitBounds(bounds, {
padding: opts.padding || [20, 20],
maxZoom: opts.maxZoom || 16
});
return true;
}
// Export functions for use in other modules
window.GeoUtils = {
isValidCoord,
hasValidGps,
isValidLatLng,
withValidGps,
fitMapToPoints
};
+7 -5
View File
@@ -662,9 +662,7 @@ let paMapSelection = { msgId: null, echoIdx: null };
let paPicks = {}; // token -> public_key chosen by the user (collision disambiguation)
function paGeoContact(c) {
return c.adv_lat !== null && c.adv_lat !== undefined &&
c.adv_lon !== null && c.adv_lon !== undefined &&
!(Number(c.adv_lat) === 0 && Number(c.adv_lon) === 0);
return hasValidGps(c);
}
// Overlay toggles live on the map itself, not in the shared filter bar -
@@ -835,8 +833,12 @@ function paDrawPaths(fit) {
});
}
if (fit && latlngs.length > 0) {
paMap.fitBounds(L.latLngBounds(latlngs).pad(0.25), { maxZoom: 13 });
if (fit) {
const fittable = latlngs.filter(isValidLatLng);
if (fittable.length > 0) {
const bounds = L.latLngBounds(fittable).pad(0.25);
if (bounds.isValid()) paMap.fitBounds(bounds, { maxZoom: 13 });
}
}
document.getElementById('paMapClearBtn').classList.remove('d-none');
+6 -8
View File
@@ -164,7 +164,7 @@ function renderHeader() {
const ZWSP = String.fromCharCode(0x200B);
document.getElementById('rptPath').textContent = path.replace(/→/g, '→' + ZWSP);
const loc = (r.adv_lat != null && r.adv_lon != null && (r.adv_lat !== 0 || r.adv_lon !== 0))
const loc = hasValidGps(r)
? `${r.adv_lat.toFixed(4)}, ${r.adv_lon.toFixed(4)}`
: '—';
document.getElementById('rptLocation').textContent = loc;
@@ -856,8 +856,8 @@ function renderNeighborsList() {
}
// Map view is offered when the repeater or any neighbour has a position
const mappable = entries.some(n => n.lat != null && n.lon != null)
|| (_repeater && _repeater.adv_lat && _repeater.adv_lon);
const mappable = entries.some(n => hasValidGps(n, 'lat', 'lon'))
|| hasValidGps(_repeater);
if (toggle) toggle.style.display = mappable ? '' : 'none';
if (!entries.length) {
@@ -923,7 +923,7 @@ function renderNeighborsMap() {
}
_nbMapLayers.clearLayers();
const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon;
const hasCenter = hasValidGps(_repeater);
const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null;
const bounds = [];
@@ -943,7 +943,7 @@ function renderNeighborsMap() {
let placed = 0;
let skipped = 0;
(data.entries || []).forEach(n => {
if (n.lat == null || n.lon == null) { skipped++; return; }
if (!hasValidGps(n, 'lat', 'lon')) { skipped++; return; }
const pos = [n.lat, n.lon];
const marker = L.circleMarker(pos, {
radius: 9,
@@ -986,9 +986,7 @@ function renderNeighborsMap() {
// Leaflet needs a size recalc after the container becomes visible
setTimeout(() => {
_nbMap.invalidateSize();
if (bounds.length > 0) {
_nbMap.fitBounds(bounds, { padding: [30, 30] });
}
fitMapToPoints(_nbMap, bounds, { padding: [30, 30] });
}, 50);
}
+2 -6
View File
@@ -1027,9 +1027,7 @@ async function loadRepeaterMapMarkers() {
}
}
let repeaters = (_repeatersCache || []).filter(r =>
r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0)
);
let repeaters = withValidGps(_repeatersCache);
if (!showCached) {
// Non-cached: only repeaters that are on the device
@@ -1082,9 +1080,7 @@ async function loadRepeaterMapMarkers() {
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
if (bounds.length > 0) {
_rptMap.fitBounds(bounds, { padding: [20, 20] });
}
fitMapToPoints(_rptMap, bounds);
}
// ================================================================
+2 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'mc-webui-v10';
const CACHE_NAME = 'mc-webui-v11';
const ASSETS_TO_CACHE = [
'/',
'/static/css/style.css',
@@ -7,6 +7,7 @@ const ASSETS_TO_CACHE = [
'/static/js/contacts.js',
'/static/js/message-utils.js',
'/static/js/filter-utils.js',
'/static/js/geo-utils.js',
'/static/js/i18n-runtime.js',
'/static/js/datetime-utils.js',
'/static/js/console.js',
+3
View File
@@ -1567,6 +1567,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- Share token formats (must load before message-utils.js, which renders them) -->
<script src="{{ url_for('static', filename='js/share-tokens.js') }}"></script>
+3
View File
@@ -324,6 +324,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- Contact Management JavaScript -->
<script src="{{ url_for('static', filename='js/contacts.js') }}"></script>
+3
View File
@@ -415,6 +415,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
+3
View File
@@ -718,6 +718,9 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- Path Analyzer JS -->
<script src="{{ url_for('static', filename='js/path-analyzer.js') }}"></script>
</body>
+3
View File
@@ -458,6 +458,9 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- Repeater Management JS -->
<script src="{{ url_for('static', filename='js/repeater-manage.js') }}"></script>
</body>
+3
View File
@@ -449,6 +449,9 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- My Repeaters JS -->
<script src="{{ url_for('static', filename='js/repeaters.js') }}"></script>
</body>
+2
View File
@@ -16,6 +16,8 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### Fixes
- **One corrupted advert no longer empties the contacts map.** Switching *Cache* on over the contacts map could leave you looking at a blank map of the world with not one of your contacts on it, and the Path Analyzer could draw a route as a single line stretching across the globe. Nothing was wrong with the contacts you could not see. Adverts arrive over the radio unchecked, and a packet mangled in flight can carry a position that is not a position at all — a latitude of 1642 degrees, a longitude of -1768. One such entry is enough, because the map fits itself around everything it is given: it zooms out until that impossible point is on screen, and every real contact ends up several world-widths away, outside the window. The tester who reported this had exactly two of them among 2131 cached contacts. Positions outside the range the Earth actually has are now thrown away rather than stored, thrown away again on the way out to the browser so a cache that already holds some heals itself the moment you update, and refused once more by the map itself. The contact is still kept and still listed — it simply no longer claims to be anywhere. The same check now guards every map in the app: contacts, repeaters, a repeater's neighbours and the Path Analyzer, which had its own looser version of the rule and would place a node reporting a longitude of exactly zero off the coast of Africa.
- **New messages on the channel you had open no longer arrive in silence, and old unread ones stop announcing themselves as new.** Two separate faults made notifications look unreliable even while the app was awake and connected. The first: a message on whichever channel you last had on screen was quietly excluded from notifications, on the assumption that you were looking at it — which is exactly wrong once the app is in the background, and meant that anyone who left mc-webui on their busiest channel was told about every channel except that one. A channel now counts as open only while somebody is actually looking at it. The second: the first time the app compared its unread counts against the server it treated the entire backlog as having just arrived, so a page that loaded in the background could announce *New: 492 channels* about messages that were days old. That first comparison is now only a measurement, and anything already unread when the app starts is treated as history rather than news.
- **Still unresolved: notifications going quiet after a while on Android.** The changes above fix what a notification says and where it takes you, but not the separate problem of the Android app falling silent some minutes after you switch away from it. That has a different cause — Android freezes apps that have nothing running in the foreground, which stops the connection the notifications depend on — and no setting on the phone can prevent it. Fixing it properly needs a background service in the app itself, which is planned for a later release.
+157
View File
@@ -0,0 +1,157 @@
"""
Coordinate sanitation for advert-sourced positions.
Regression cover for a tester's blank contacts map (2026-08-28): two corrupted
adverts in a 2861-contact cache carried latitudes of 1642 and 309 and
longitudes of -323 and -1768. Leaflet clamps latitude when projecting but not
longitude, so fitBounds() spanned 1797 degrees, dropped to zoom 0 and pushed
all 2273 real markers off-screen.
Run: python -m pytest tests/test_geo.py -v
"""
import tempfile
from pathlib import Path
import pytest
from app.database import Database
from app.geo import (
MAX_LAT,
MAX_LON,
is_valid_contact_type,
sanitize_contact_latlon,
sanitize_latlon,
scrub_geo,
)
@pytest.fixture
def db():
with tempfile.TemporaryDirectory() as tmp:
yield Database(Path(tmp) / 'test.db')
# ================================================================
# sanitize_latlon
# ================================================================
class TestSanitizeLatLon:
@pytest.mark.parametrize('lat,lon', [
(50.866005, 20.669308),
(0.0, 0.0), # "no fix" placeholder is in range, callers filter it
(MAX_LAT, MAX_LON),
(-MAX_LAT, -MAX_LON),
])
def test_valid_pairs_survive(self, lat, lon):
assert sanitize_latlon(lat, lon) == (lat, lon)
@pytest.mark.parametrize('lat,lon', [
(1642.584589, -323.210017), # 'j000' - the REP that blanked the map
(309.480255, -1768.532171), # 'Supreme-PL-WAW' - the COM that blanked the map
(-495.68555, -1192.263528),
(90.000001, 20.0),
(50.0, 180.000001),
(float('nan'), 20.0),
(float('inf'), 20.0),
(50.0, float('-inf')),
])
def test_out_of_range_is_dropped(self, lat, lon):
assert sanitize_latlon(lat, lon) == (None, None)
@pytest.mark.parametrize('lat,lon', [
(None, None),
(50.0, None), # half a position is not a position
(None, 20.0),
('abc', 20.0),
(object(), 20.0),
])
def test_unusable_input_yields_none_pair(self, lat, lon):
assert sanitize_latlon(lat, lon) == (None, None)
def test_numeric_strings_are_accepted(self):
assert sanitize_latlon('50.5', '21.5') == (50.5, 21.5)
def test_logs_what_it_drops(self, caplog):
with caplog.at_level('WARNING'):
assert sanitize_contact_latlon(1642.58, -323.21, label='j000') == (None, None)
assert 'j000' in caplog.text
def test_silent_when_nothing_to_drop(self, caplog):
with caplog.at_level('WARNING'):
sanitize_contact_latlon(50.0, 20.0, label='ok')
sanitize_contact_latlon(None, None, label='no position')
assert caplog.text == ''
# ================================================================
# scrub_geo (API output)
# ================================================================
class TestScrubGeo:
def test_nulls_out_of_range_pair(self):
payload = {'name': 'j000', 'adv_lat': 1642.58, 'adv_lon': -323.21}
assert scrub_geo(payload) == {'name': 'j000', 'adv_lat': None, 'adv_lon': None}
def test_leaves_valid_pair_alone(self):
payload = {'name': 'ok', 'adv_lat': 50.1, 'adv_lon': 20.2}
assert scrub_geo(dict(payload)) == payload
def test_does_not_invent_keys(self):
assert scrub_geo({'name': 'no position'}) == {'name': 'no position'}
def test_honours_custom_keys(self):
payload = {'lat': 1994.5, 'lon': 1118.9}
assert scrub_geo(payload, 'lat', 'lon') == {'lat': None, 'lon': None}
# ================================================================
# Contact type
# ================================================================
class TestContactType:
@pytest.mark.parametrize('value', [0, 1, 2, 3, 4])
def test_known_types(self, value):
assert is_valid_contact_type(value)
@pytest.mark.parametrize('value', [42, 61, 126, 152, 210, -1, None, 'REP', True, 2.0])
def test_corrupted_types(self, value):
assert not is_valid_contact_type(value)
# ================================================================
# Write path
# ================================================================
class TestUpsertContact:
def test_garbage_coordinates_never_reach_the_cache(self, db):
db.upsert_contact('AA' * 32, name='j000', type=2,
adv_lat=1642.584589, adv_lon=-323.210017)
row = db.get_contact('aa' * 32)
assert row['adv_lat'] is None and row['adv_lon'] is None
assert row['name'] == 'j000' # the contact itself is still kept
def test_valid_coordinates_are_stored(self, db):
db.upsert_contact('BB' * 32, name='ok', type=2, adv_lat=50.5, adv_lon=21.5)
row = db.get_contact('bb' * 32)
assert (row['adv_lat'], row['adv_lon']) == (50.5, 21.5)
def test_corrupted_advert_does_not_erase_a_known_position(self, db):
pk = 'CC' * 32
db.upsert_contact(pk, name='ok', type=2, adv_lat=50.5, adv_lon=21.5)
db.upsert_contact(pk, name='ok', type=2, adv_lat=1642.5, adv_lon=-323.2)
row = db.get_contact(pk.lower())
assert (row['adv_lat'], row['adv_lon']) == (50.5, 21.5)
def test_corrupted_type_is_kept_but_flagged(self, db, caplog):
with caplog.at_level('WARNING'):
db.upsert_contact('DD' * 32, name='LANDA 2 R', type=61,
adv_lat=542.26, adv_lon=1163.55)
row = db.get_contact('dd' * 32)
assert row['type'] == 61 # raw value preserved as evidence
assert row['adv_lat'] is None
assert 'LANDA 2 R' in caplog.text