diff --git a/app/database.py b/app/database.py index ebeb9da..e461a52 100644 --- a/app/database.py +++ b/app/database.py @@ -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), } diff --git a/app/geo.py b/app/geo.py new file mode 100644 index 0000000..78e3a1f --- /dev/null +++ b/app/geo.py @@ -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 diff --git a/app/routes/api.py b/app/routes/api.py index dbe96fb..53c663f 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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) diff --git a/app/static/js/app.js b/app/static/js/app.js index 3fe254d..ead182d 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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: '', 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(`${c.name}
${typeName}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`); + L.circleMarker([c.adv_lat, c.adv_lon], { + radius: 10, + fillColor: color, + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }) + .addTo(markersGroup) + .bindPopup(`${c.name}
${typeName}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`); - 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(`${c.name}
${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`); + 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(`${c.name}
${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`); - 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 diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index fafc1f9..b61336e 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -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 = ` ${tHtml('contacts.map.title')}`; @@ -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 = ` ${tHtml('contacts.map.title')}`; diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 2c28a65..cfddb07 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -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 = ` ${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); } /** diff --git a/app/static/js/geo-utils.js b/app/static/js/geo-utils.js new file mode 100644 index 0000000..c0eebde --- /dev/null +++ b/app/static/js/geo-utils.js @@ -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>} 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 +}; diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 7fe2f28..0bc1322 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -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'); diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 1942109..fd8f63f 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -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); } diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js index e30b3ee..16ec50c 100644 --- a/app/static/js/repeaters.js +++ b/app/static/js/repeaters.js @@ -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); } // ================================================================ diff --git a/app/static/js/sw.js b/app/static/js/sw.js index 99a5c50..518c261 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -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', diff --git a/app/templates/base.html b/app/templates/base.html index 4e15c91..b99ae3f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1567,6 +1567,9 @@ integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""> + + + diff --git a/app/templates/contacts_base.html b/app/templates/contacts_base.html index 7d682b8..243fa74 100644 --- a/app/templates/contacts_base.html +++ b/app/templates/contacts_base.html @@ -324,6 +324,9 @@ integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""> + + + diff --git a/app/templates/dm.html b/app/templates/dm.html index 5380a6e..14a6c98 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -415,6 +415,9 @@ integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""> + + + diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index e0e89b4..46548bd 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -718,6 +718,9 @@ + + + diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index 536c038..a9a637e 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -458,6 +458,9 @@ + + + diff --git a/app/templates/repeaters.html b/app/templates/repeaters.html index abcab68..e70e88c 100644 --- a/app/templates/repeaters.html +++ b/app/templates/repeaters.html @@ -449,6 +449,9 @@ + + + diff --git a/docs/whatsnew.md b/docs/whatsnew.md index 3e63f33..54ef995 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -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. diff --git a/tests/test_geo.py b/tests/test_geo.py new file mode 100644 index 0000000..7bf7aec --- /dev/null +++ b/tests/test_geo.py @@ -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