Files
alro65 cfd94f905a security: CORS hardening, path traversal fix, WebSocket auth + cleanup
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:45:43 -04:00

106 lines
3.7 KiB
Python

"""
Runtime settings store. Persists the settings the user changes via the
SETTINGS modal to a sidecar JSON file so they survive restarts.
The single mutable `SETTINGS` dict is the source of truth at runtime —
import it, don't copy it. Endpoints should call `apply_patch()` (which
also writes the file) rather than mutating directly.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from threading import Lock
_PATH = Path(__file__).parent.parent / "settings.json"
_LOCK = Lock()
DEFAULTS: dict = {
# AIS source
"ais_source": os.getenv("AIS_SOURCE", "SIMULATOR"),
"ais_serial_port": "",
"ais_baud": 38400,
"ais_net_addr": "",
# GPS (mirrored from .env at startup; the GPS hot-change endpoint also writes here)
"gps_port": os.getenv("GPS_PORT", ""),
"gps_baud": int(os.getenv("GPS_BAUD", 9600) or 9600),
# Station / antenna (purely descriptive for now; antenna position is
# exposed to the frontend so it can centre the map and run proximity
# checks against the station)
"station_name": "",
"iala_region": "B",
"antenna_lat": None,
"antenna_lon": None,
"antenna_height_m": None,
"station_notes": "",
# Alert thresholds (live — alert_engine reads these every evaluation)
"displacement_warn_m": 10.0,
"displacement_alarm_m": 15.0,
"proximity_alert_meters": int(os.getenv("PROXIMITY_ALERT_METERS", 500)),
"projection_minutes": int(os.getenv("PROJECTION_MINUTES", 10)),
"projection_radius_meters": 800,
"auto_record_trigger_m": 200.0,
# ATON battery thresholds (volts)
"battery_warn_v": 11.5,
"battery_alarm_v": 10.8,
# SMTP — operator's organisation email account that sends report emails.
# When configured, /alerts/report sends EMAIL via SMTP instead of opening
# the operator's local mail client. Leave smtp_host blank to disable.
"smtp_host": "",
"smtp_port": 587,
"smtp_user": "",
"smtp_password": "",
"smtp_from": "",
"smtp_from_name": "AidsMonitoring",
"smtp_use_tls": True,
# ── Cluster / multi-server role ──────────────────────────────────────────
# STANDALONE: single server (default)
# MASTER : central aggregator — accepts connections from slave servers
# SLAVE : field server — forwards all events to the master
"server_role": os.getenv("SERVER_ROLE", "STANDALONE"),
# URL of the master's slave WebSocket endpoint (required when SLAVE)
# Example: "ws://10.0.0.1:8000/ws/slave"
"master_url": os.getenv("MASTER_URL", ""),
# Human-readable name for this slave (shown in master's status panel)
"slave_name": os.getenv("SLAVE_NAME", ""),
}
SETTINGS: dict = dict(DEFAULTS)
def _load_from_disk() -> dict:
if not _PATH.exists():
return {}
try:
return json.loads(_PATH.read_text())
except Exception:
return {}
def _flush_to_disk():
try:
_PATH.write_text(json.dumps(SETTINGS, indent=2))
except Exception:
pass
def init():
"""Call once at app startup — merges saved overrides into SETTINGS."""
SETTINGS.update(_load_from_disk())
return SETTINGS
def get_all() -> dict:
return dict(SETTINGS)
def apply_patch(patch: dict) -> dict:
"""Merge patch into SETTINGS, ignoring unknown keys, write to disk.
Returns the new full settings dict."""
with _LOCK:
for k, v in patch.items():
if k in DEFAULTS:
SETTINGS[k] = v
_flush_to_disk()
return get_all()