diff --git a/routes/settings.py b/routes/settings.py index 8a094b0..03c82e0 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -1,26 +1,101 @@ """Settings management routes.""" -from __future__ import annotations - -import os -import subprocess -import sys - -from flask import Blueprint, Response, jsonify, request - -from utils.database import ( +from __future__ import annotations + +import contextlib +import os +import re +import subprocess +import sys +import threading +from pathlib import Path + +from flask import Blueprint, Response, jsonify, request + +from utils.database import ( delete_setting, get_all_settings, get_correlations, get_setting, set_setting, -) -from utils.logging import get_logger -from utils.responses import api_error, api_success - -logger = get_logger('intercept.settings') - -settings_bp = Blueprint('settings', __name__, url_prefix='/settings') +) +from utils.logging import get_logger +from utils.responses import api_error, api_success +from utils.validation import validate_latitude, validate_longitude + +logger = get_logger('intercept.settings') + +settings_bp = Blueprint('settings', __name__, url_prefix='/settings') +_env_lock = threading.Lock() + + +def _get_env_file_path() -> Path: + """Return the project .env path.""" + return Path(__file__).resolve().parent.parent / '.env' + + +def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None: + """Create or update a single key in the project .env file.""" + path = env_path or _get_env_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + + with _env_lock: + lines = path.read_text().splitlines() if path.exists() else [ + '# INTERCEPT environment configuration', + '', + ] + + pattern = re.compile(rf'^\s*{re.escape(key)}=') + updated = False + new_lines: list[str] = [] + for line in lines: + if pattern.match(line): + if not updated: + new_lines.append(f'{key}={value}') + updated = True + continue + new_lines.append(line) + + if not updated: + if new_lines and new_lines[-1] != '': + new_lines.append('') + new_lines.append(f'{key}={value}') + + path.write_text('\n'.join(new_lines).rstrip('\n') + '\n') + + sudo_uid = os.environ.get('INTERCEPT_SUDO_UID') + sudo_gid = os.environ.get('INTERCEPT_SUDO_GID') + if os.geteuid() == 0 and sudo_uid and sudo_gid: + with contextlib.suppress(OSError, ValueError): + os.chown(path, int(sudo_uid), int(sudo_gid)) + + +def _apply_runtime_observer_defaults(lat: float, lon: float) -> None: + """Update in-process defaults so refreshed pages use the saved location.""" + lat_str = str(lat) + lon_str = str(lon) + os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str + os.environ['INTERCEPT_DEFAULT_LON'] = lon_str + + import config + + config.DEFAULT_LATITUDE = lat + config.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + import app as app_module + app_module.DEFAULT_LATITUDE = lat + app_module.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + from routes import adsb as adsb_routes + adsb_routes.DEFAULT_LATITUDE = lat + adsb_routes.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + from routes import ais as ais_routes + ais_routes.DEFAULT_LATITUDE = lat + ais_routes.DEFAULT_LONGITUDE = lon @settings_bp.route('', methods=['GET']) @@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response: return api_error(str(e), 500) -@settings_bp.route('/', methods=['DELETE']) -def delete_single_setting(key: str) -> Response: +@settings_bp.route('/', methods=['DELETE']) +def delete_single_setting(key: str) -> Response: """Delete a setting.""" try: deleted = delete_setting(key) @@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response: }), 404 except Exception as e: logger.error(f"Error deleting setting {key}: {e}") - return api_error(str(e), 500) + return api_error(str(e), 500) + + +@settings_bp.route('/observer-location', methods=['POST']) +def save_observer_location() -> Response: + """Persist observer location to .env and refresh in-process defaults.""" + data = request.json or {} + + try: + lat = validate_latitude(data.get('lat')) + lon = validate_longitude(data.get('lon')) + except ValueError as exc: + return api_error(str(exc), 400) + + try: + _write_env_value('INTERCEPT_DEFAULT_LAT', str(lat)) + _write_env_value('INTERCEPT_DEFAULT_LON', str(lon)) + _apply_runtime_observer_defaults(lat, lon) + return api_success( + data={ + 'lat': lat, + 'lon': lon, + 'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'], + }, + message='Observer location saved to .env', + ) + except Exception as exc: + logger.error(f'Error saving observer location to .env: {exc}') + return api_error(str(exc), 500) # ============================================================================= diff --git a/static/js/core/observer-location.js b/static/js/core/observer-location.js index 53b8c5d..3a05a30 100644 --- a/static/js/core/observer-location.js +++ b/static/js/core/observer-location.js @@ -1,9 +1,6 @@ // Shared observer location helper for map-based modules. // Default: shared location enabled unless explicitly disabled via config. window.ObserverLocation = (function() { - const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON) - ? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON } - : { lat: 51.5074, lon: -0.1278 }; const SHARED_KEY = 'observerLocation'; const AIS_KEY = 'ais_observerLocation'; const LEGACY_LAT_KEY = 'observerLat'; @@ -21,6 +18,9 @@ window.ObserverLocation = (function() { return { lat: latNum, lon: lonNum }; } + const DEFAULT_LOCATION = normalize(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON) + || { lat: 51.5074, lon: -0.1278 }; + function parseLocation(raw) { if (!raw) return null; try { @@ -39,7 +39,7 @@ window.ObserverLocation = (function() { function readLegacyLatLon() { const lat = localStorage.getItem(LEGACY_LAT_KEY); const lon = localStorage.getItem(LEGACY_LON_KEY); - if (!lat || !lon) return null; + if (lat === null || lon === null) return null; return normalize(lat, lon); } @@ -60,11 +60,12 @@ window.ObserverLocation = (function() { } function setShared(location, options = {}) { - if (!location) return; - localStorage.setItem(SHARED_KEY, JSON.stringify(location)); + const normalized = location ? normalize(location.lat, location.lon) : null; + if (!normalized) return; + localStorage.setItem(SHARED_KEY, JSON.stringify(normalized)); if (options.updateLegacy !== false) { - localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); - localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); + localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString()); + localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString()); } } @@ -84,16 +85,17 @@ window.ObserverLocation = (function() { } function setForModule(moduleKey, location, options = {}) { - if (!location) return; + const normalized = location ? normalize(location.lat, location.lon) : null; + if (!normalized) return; if (isSharedEnabled()) { - setShared(location, options); + setShared(normalized, options); return; } if (moduleKey) { - localStorage.setItem(moduleKey, JSON.stringify(location)); + localStorage.setItem(moduleKey, JSON.stringify(normalized)); } else if (options.fallbackToLatLon) { - localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); - localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); + localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString()); + localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString()); } } diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index c3faebe..1841a3f 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -896,23 +896,26 @@ function loadObserverLocation() { lon = shared.lon.toString(); } + const hasLat = lat !== undefined && lat !== null && lat !== ''; + const hasLon = lon !== undefined && lon !== null && lon !== ''; + const latInput = document.getElementById('observerLatInput'); const lonInput = document.getElementById('observerLonInput'); const currentLatDisplay = document.getElementById('currentLatDisplay'); const currentLonDisplay = document.getElementById('currentLonDisplay'); - if (latInput && lat) latInput.value = lat; - if (lonInput && lon) lonInput.value = lon; + if (latInput && hasLat) latInput.value = lat; + if (lonInput && hasLon) lonInput.value = lon; if (currentLatDisplay) { - currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; + currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; } if (currentLonDisplay) { - currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; + currentLonDisplay.textContent = hasLon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; } // Sync dashboard-specific location keys for backward compatibility - if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') { + if (hasLat && hasLon) { const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); if (!localStorage.getItem('observerLocation')) { localStorage.setItem('observerLocation', locationObj); @@ -1011,9 +1014,9 @@ function detectLocationGPS(btn) { } /** - * Save observer location to localStorage + * Save observer location to localStorage and persist defaults to .env */ -function saveObserverLocation() { +async function saveObserverLocation() { const latInput = document.getElementById('observerLatInput'); const lonInput = document.getElementById('observerLonInput'); @@ -1056,19 +1059,40 @@ function saveObserverLocation() { if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°'; if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°'; - if (typeof showNotification === 'function') { - showNotification('Location', 'Observer location saved'); - } - if (window.observerLocation) { window.observerLocation.lat = lat; window.observerLocation.lon = lon; } + let notificationMessage = 'Observer location saved'; + + try { + const response = await fetch('/settings/observer-location', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lat, lon }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.status === 'error') { + throw new Error(data.message || 'Failed to save observer location to .env'); + } + window.INTERCEPT_DEFAULT_LAT = lat; + window.INTERCEPT_DEFAULT_LON = lon; + notificationMessage = 'Observer location saved to settings and .env'; + } catch (error) { + notificationMessage = `Observer location saved for this browser, but .env update failed: ${error.message}`; + } + // Refresh SSTV ISS schedule if available if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { SSTV.loadIssSchedule(); } + + if (typeof showNotification === 'function') { + showNotification('Location', notificationMessage); + } } // ============================================================================= diff --git a/templates/index.html b/templates/index.html index e0bc6ab..56ca5be 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3890,7 +3890,11 @@ if (saved) { try { const parsed = JSON.parse(saved); - if (parsed.lat && parsed.lon) return parsed; + const lat = Number(parsed.lat); + const lon = Number(parsed.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) { + return { lat, lon }; + } } catch (e) { } } return { lat: 51.5074, lon: -0.1278 }; @@ -9857,16 +9861,16 @@ (function _seedAprsLocation() { if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { const shared = ObserverLocation.getShared(); - if (shared && shared.lat && shared.lon) { + if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) { aprsUserLocation.lat = shared.lat; aprsUserLocation.lon = shared.lon; return; } } // Fallback: read the Jinja-injected defaults directly - const lat = window.INTERCEPT_DEFAULT_LAT; - const lon = window.INTERCEPT_DEFAULT_LON; - if (lat && lon && Number.isFinite(lat) && Number.isFinite(lon)) { + const lat = Number(window.INTERCEPT_DEFAULT_LAT); + const lon = Number(window.INTERCEPT_DEFAULT_LON); + if (aprsHasValidCoordinates(lat, lon)) { aprsUserLocation.lat = lat; aprsUserLocation.lon = lon; } @@ -10824,25 +10828,26 @@ }); function updateLocationFromGps(position) { - if (!position || !position.latitude || !position.longitude) { + const lat = Number(position && position.latitude); + const lon = Number(position && position.longitude); + const fixQuality = Number(position && position.fix_quality); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { return; } + if (Number.isFinite(fixQuality) && fixQuality < 2) return; // Update satellite observer location const satLatInput = document.getElementById('obsLat'); const satLonInput = document.getElementById('obsLon'); - if (satLatInput) satLatInput.value = position.latitude.toFixed(4); - if (satLonInput) satLonInput.value = position.longitude.toFixed(4); + if (satLatInput) satLatInput.value = lat.toFixed(4); + if (satLonInput) satLonInput.value = lon.toFixed(4); // Update observerLocation - observerLocation.lat = position.latitude; - observerLocation.lon = position.longitude; - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude }); - } + observerLocation.lat = lat; + observerLocation.lon = lon; - // Update APRS user location - updateAprsUserLocation(position); + // Keep live GPS separate from the configured shared observer location. + updateAprsUserLocation({ latitude: lat, longitude: lon }); } function showGpsIndicator(show) { diff --git a/tests/test_routes.py b/tests/test_routes.py index cfbf0ef..5483786 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,9 +1,9 @@ -"""Tests for Flask routes and API endpoints.""" - -import json -from unittest.mock import MagicMock, patch - -import pytest +"""Tests for Flask routes and API endpoints.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest @pytest.fixture(scope='session') @@ -103,8 +103,8 @@ class TestDependenciesEndpoint: assert 'modes' in data -class TestSettingsEndpoints: - """Tests for settings API endpoints.""" +class TestSettingsEndpoints: + """Tests for settings API endpoints.""" def test_get_settings(self, client): """Test getting all settings.""" @@ -172,8 +172,8 @@ class TestSettingsEndpoints: assert data['status'] == 'success' assert data['value'] == 'updated_value' - def test_delete_setting(self, client): - """Test deleting a setting.""" + def test_delete_setting(self, client): + """Test deleting a setting.""" # First create a setting client.post( '/settings', @@ -185,9 +185,60 @@ class TestSettingsEndpoints: response = client.delete('/settings/delete_me') assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert data['deleted'] is True + data = json.loads(response.data) + assert data['status'] == 'success' + assert data['deleted'] is True + + def test_save_observer_location_updates_env_and_runtime_defaults(self, client, monkeypatch, tmp_path): + """Saving observer location should persist to .env and update in-memory defaults.""" + import app as app_module + import config + from routes import adsb as adsb_routes + from routes import ais as ais_routes + from routes import settings as settings_routes + + with client.session_transaction() as sess: + sess['logged_in'] = True + + env_path = tmp_path / '.env' + monkeypatch.setattr(settings_routes, '_get_env_file_path', lambda: env_path) + + response = client.post( + '/settings/observer-location', + data=json.dumps({'lat': 48.0, 'lon': 16.16}), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'success' + assert data['lat'] == 48.0 + assert data['lon'] == 16.16 + + env_text = env_path.read_text() + assert 'INTERCEPT_DEFAULT_LAT=48.0' in env_text + assert 'INTERCEPT_DEFAULT_LON=16.16' in env_text + + assert config.DEFAULT_LATITUDE == 48.0 + assert config.DEFAULT_LONGITUDE == 16.16 + assert app_module.DEFAULT_LATITUDE == 48.0 + assert app_module.DEFAULT_LONGITUDE == 16.16 + assert adsb_routes.DEFAULT_LATITUDE == 48.0 + assert adsb_routes.DEFAULT_LONGITUDE == 16.16 + assert ais_routes.DEFAULT_LATITUDE == 48.0 + assert ais_routes.DEFAULT_LONGITUDE == 16.16 + + def test_save_observer_location_rejects_invalid_values(self, client): + """Observer location save should validate coordinates.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + + response = client.post( + '/settings/observer-location', + data=json.dumps({'lat': 200, 'lon': 16.16}), + content_type='application/json' + ) + assert response.status_code == 400 class TestCorrelationEndpoints: