#!/usr/bin/env python3 """ MeshCore Bot Data Viewer Bot montoring web interface using Flask-SocketIO 5.x """ import configparser import functools import hmac import json import logging import os import re import secrets import sqlite3 import sys import threading import time from collections.abc import Callable, Iterator from contextlib import closing, contextmanager, suppress from datetime import datetime, timedelta, timezone # noqa: F401 timedelta kept for modules.web_viewer.app.timedelta from pathlib import Path from typing import Any, NamedTuple # noqa: F401 NamedTuple kept for modules.web_viewer.app.NamedTuple from urllib.parse import urlparse # When started as a script (`python modules/web_viewer/app.py`), Python puts the # script's directory on sys.path, not the repo root — import modules.* fails # unless we prepend the project root first. _project_root = str(Path(__file__).resolve().parent.parent.parent) if _project_root not in sys.path: sys.path.insert(0, _project_root) from flask import ( Flask, Response, abort, current_app, g, jsonify, make_response, redirect, render_template, request, send_from_directory, session, url_for, ) from flask_socketio import ( # noqa: F401 kept for modules.web_viewer.app.disconnect and .emit SocketIO, disconnect, emit, ) from modules import flood_scope, region_warning from modules.database_restore import ( DEFAULT_MAX_RESTORE_BYTES, DatabaseRestoreError, stage_database_restore, ) from modules.db_retention import ( # noqa: F401 kept for modules.web_viewer.app. delete_timestamp_rows_in_chunks, retention_delete_settings, ) from modules.ini_writer import IniValueError, update_ini_values from modules.maintenance import MaintenanceRunner from modules.models import CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD, channel_body_limit from modules.scheduled_message_admin import ( SECTION as SCHEDULED_MESSAGES_SECTION, ) from modules.scheduled_message_admin import ( compose_value, describe_schedule, read_entries, validate_entry, ) from modules.security_utils import ( validate_external_url, validate_sql_identifier, # noqa: F401 kept for modules.web_viewer.app.validate_sql_identifier ) from modules.settings_schema import ( build_plugin_settings_view, to_config_string, validate_field, ) from modules.settings_store import get_settings_store from modules.version_info import resolve_application_version from modules.web_viewer.dashboard_stats import ( SERIES_METRICS, TOP_KINDS, DashboardStatsService, # noqa: F401 kept for modules.web_viewer.app.DashboardStatsService humanize_span, # noqa: F401 kept for modules.web_viewer.app.humanize_span ) # RFC 8594 Sunset date advertised on the deprecated /api/stats endpoint. STATS_ENDPOINT_SUNSET = "Fri, 01 Jan 2027 00:00:00 GMT" def _validate_dynamic_key(key: str) -> "str | None": """Validate a dynamic-section row key. Returns an error message or None. Keys become INI option names, so they must not contain the separators or comment markers that would corrupt the file on the next read. """ if any(ch in key for ch in ('=', ':', '\n', '\r', '[', ']')): return f'Invalid key "{key}": cannot contain = : [ ] or newlines' if key[:1] in ('#', ';'): return f'Invalid key "{key}": cannot start with # or ;' return None def _apply_werkzeug_websocket_fix() -> None: """Patch SimpleWebSocketWSGI to call start_response after WebSocket teardown. python-engineio's SimpleWebSocketWSGI.__call__ handles the WebSocket session directly on the raw socket and returns [] without ever calling start_response. Werkzeug then calls write(b"") to flush an empty body, which triggers ``AssertionError: write() before start_response``. The fix calls start_response after the handler returns so that status_set is not None when write(b"") runs. The subsequent attempt to write HTTP headers to the already-closed socket raises BrokenPipeError, which Werkzeug classifies as a dropped connection and silently ignores. """ try: from engineio.async_drivers import _websocket_wsgi # noqa: PLC0415 _orig_call = _websocket_wsgi.SimpleWebSocketWSGI.__call__ def _patched_call(self, environ, start_response): # noqa: ANN001 result = _orig_call(self, environ, start_response) try: start_response('200 OK', [('Content-Length', '0')]) except Exception: # noqa: BLE001 pass return result _websocket_wsgi.SimpleWebSocketWSGI.__call__ = _patched_call except (ImportError, AttributeError): pass _apply_werkzeug_websocket_fix() from modules.config_snapshot import config_to_redacted_sections # Imported for the feed helpers that moved to modules.web_viewer.feeds; kept so # modules.web_viewer.app. (and patches of it) keep resolving. from modules.feed_filter_eval import ( # noqa: F401 get_nested_value, item_passes_filter_config, # noqa: F401 parse_microsoft_date, ) from modules.feed_format import format_feed_message, sort_feed_items # noqa: F401 from modules.feed_manager import ( # noqa: F401 DEFAULT_MAX_FEED_RESPONSE_BYTES, DEFAULT_MAX_PARSED_FEED_ITEMS, _useful_feed_content_type, # noqa: F401 ) from modules.feed_parse import ( # noqa: F401 api_item_fields, feed_allow_private_urls, feed_max_parsed_items, feed_max_response_bytes, rss_entry_published, ) from modules.repeater_manager import RepeaterManager, validate_repeater_tables from modules.security_utils import SafeUrlPolicy, create_safe_requests_session, safe_requests_request # noqa: F401 from modules.utils import resolve_path from modules.web_viewer.channels import ChannelAdminMixin from modules.web_viewer.cleanup import CleanupSchedulerMixin from modules.web_viewer.config_panels import CONFIG_PANELS, PANEL_CATEGORIES from modules.web_viewer.dashboard import DashboardSnapshotMixin from modules.web_viewer.database_admin import DatabaseAdminMixin from modules.web_viewer.feeds import ( # noqa: F401 _read_limited_requests_response and _validate_feed_interval re-exported FeedSubscriptionsMixin, _read_limited_requests_response, _validate_feed_interval, ) from modules.web_viewer.integration import normalized_web_viewer_password from modules.web_viewer.live_stream import ( # noqa: F401 _ANSI_ESCAPE_RE re-exported _ANSI_ESCAPE_RE, LiveStreamMixin, _strip_ansi_codes, ) from modules.web_viewer.mesh_evidence import ( # noqa: F401 NeighborEvidenceKeys re-exported MeshEvidenceMixin, NeighborEvidenceKeys, ) from modules.web_viewer.multibyte_rollout import MultibyteRolloutMixin from modules.web_viewer.socket_clients import SocketClientsMixin from modules.web_viewer.tracking import ContactTrackingMixin class BotDataViewer(DashboardSnapshotMixin, LiveStreamMixin, SocketClientsMixin, CleanupSchedulerMixin, ChannelAdminMixin, DatabaseAdminMixin, ContactTrackingMixin, MeshEvidenceMixin, MultibyteRolloutMixin, FeedSubscriptionsMixin): """Complete web interface using Flask-SocketIO 5.x best practices""" # Whitelist of allowed tables for security ALLOWED_TABLES = { 'geocoding_cache', 'generic_cache', 'bot_metadata', 'packet_stream', 'message_stats', 'command_stats', 'greeted_users', 'repeater_contacts', 'complete_contact_tracking', 'daily_stats', 'unique_advert_packets', 'purging_log', 'mesh_connections', 'observed_paths', 'feed_subscriptions', 'feed_activity', 'feed_errors', 'feed_message_queue', 'channel_operations', 'channels', 'path_stats', 'schema_version', 'greeter_rollout', 'daily_rollup', 'dashboard_snapshot', } def __init__(self, db_path="meshcore_bot.db", repeater_db_path=None, config_path="config.ini"): # Set bot root directory (project root) for path validation # This is the directory containing the modules folder self.bot_root = Path(os.path.join(os.path.dirname(__file__), '..', '..')).resolve() # Resolve relative config path so viewer finds config when started as subprocess (cwd may differ) if not os.path.isabs(config_path): config_path = str(self.bot_root / config_path) self.config_path = config_path # kept for config.ini write-back endpoints # Resolve db_path relative to the config file's directory — matches core.py's bot_root # property which is Path(config_file).parent.resolve(). Using self.bot_root (the project # code root, 2 dirs above app.py) as the base caused a mismatch when config.ini lived # elsewhere (e.g. a separate deployment directory), resulting in a blank realtime monitor # because the web viewer and bot opened different database files. self._config_base = Path(config_path).parent.resolve() if os.path.exists(config_path) else self.bot_root # Load configuration before logging so [Logging] log_file can select # journal/console-only vs file logging (same rules as the main bot). self.config = self._load_merged_config() self._setup_logging() self.app = Flask( __name__, template_folder=os.path.join(os.path.dirname(__file__), 'templates'), static_folder=os.path.join(os.path.dirname(__file__), 'static'), static_url_path='/static' ) import secrets as _secrets self.app.config['SECRET_KEY'] = _secrets.token_hex(32) self.app.config['SESSION_COOKIE_HTTPONLY'] = True self.app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' self.app.config['PERMANENT_SESSION_LIFETIME'] = 86400 # 24 hours # Compress responses when the client supports it — /api/mesh/edges alone # is ~16 MB of JSON uncompressed (~4 MB gzipped) on a large mesh try: from flask_compress import Compress Compress(self.app) except ImportError: self.logger.warning( "flask-compress not installed; web viewer responses will be sent uncompressed" ) # Flask-SocketIO configuration following 5.x best practices # CORS origins are configured after config is loaded; create without app for now self._socketio_kwargs = dict( max_http_buffer_size=1000000, # 1MB buffer limit ping_timeout=20, # 20 second ping timeout — 5s was too short when subscribe handlers replay DB history ping_interval=25, # 25 second ping interval (Flask-SocketIO 5.x default) logger=False, # Disable verbose logging engineio_logger=False, # Disable EngineIO logging async_mode='threading', # Use threading for better stability ) self.socketio = SocketIO() self.repeater_db_path = repeater_db_path # Connection management using Flask-SocketIO built-ins self.connected_clients = {} # Track client metadata self._clients_lock = threading.Lock() # Thread safety for connected_clients self.max_clients = 10 # SQLite pragma handling (including the once-per-section WAL setup) # lives in DBManager; see _configure_db_connection. # The contacts list needs all-time multibyte hop-prefix evidence for its # capability badge. Cache that derived set between requests and invalidate # it when the underlying multibyte-path population changes. # Keyed by the recent_days window (None = all time) so the contacts list # and the dashboard's 7-day view do not evict each other. self._contacts_badge_cache_lock = threading.Lock() self._contacts_badge_cache: dict[int | None, tuple[tuple, set[str]]] = {} # The multi-byte mesh endpoint derives lifetime edge identity from every # retained multi-byte observed path. Cache that expensive aggregate and # ensure concurrent requests share one computation. View-specific day # and observation filters remain cheap and are applied to the cached # lifetime result. try: mesh_cache_seconds = self.config.getint( 'Web_Viewer', 'mesh_graph_cache_seconds', fallback=30, ) except (configparser.Error, ValueError, TypeError): mesh_cache_seconds = 30 self._mesh_graph_cache_seconds = max(5, min(mesh_cache_seconds, 300)) self._multibyte_graph_cache_condition = threading.Condition() self._multibyte_graph_cache_edges: list[dict[str, Any]] | None = None self._multibyte_graph_cache_created_at = 0.0 self._multibyte_graph_cache_computing = False self._multibyte_graph_cache_failure_at = 0.0 self._multibyte_graph_cache_failure: tuple[str, str] | None = None self._multibyte_graph_cache_retry_seconds = 5.0 # Use [Bot] db_path when [Web_Viewer] db_path is unset bot_db = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db') if (self.config.has_section('Web_Viewer') and self.config.has_option('Web_Viewer', 'db_path') and self.config.get('Web_Viewer', 'db_path', fallback='').strip()): use_db = self.config.get('Web_Viewer', 'db_path').strip() else: use_db = bot_db self.db_path = str(resolve_path(use_db, self._config_base)) self.logger.info(f"Using database: {self.db_path}") # Optional password authentication for web viewer (BUG-001) self.web_viewer_password = normalized_web_viewer_password(self.config) if self.web_viewer_password: self.logger.info("Web viewer authentication enabled") else: self.logger.warning( "Web viewer has NO authentication. Set web_viewer_password in [Web_Viewer] config " "or restrict access with host = 127.0.0.1 and firewall rules." ) # Optional feature page toggle (off by default) try: self.multibyte_monitor_enabled = self.config.getboolean( 'Web_Viewer', 'multibyte_monitor_enabled', fallback=False ) except (configparser.NoSectionError, configparser.NoOptionError, ValueError, TypeError): self.multibyte_monitor_enabled = False self._init_dashboard_service() # Configure CORS for SocketIO — default to same-origin (no cross-origin) cors_raw = self.config.get('Web_Viewer', 'cors_allowed_origins', fallback='').strip() if cors_raw: cors_origins = cors_raw if cors_raw == '*' else [o.strip() for o in cors_raw.split(',') if o.strip()] self._socketio_kwargs['cors_allowed_origins'] = cors_origins # Initialize SocketIO with Flask app now that config is loaded self.socketio.init_app(self.app, **self._socketio_kwargs) # Version info for footer (tag or branch/commit/date); computed once at startup self._version_info = self._get_version_info() # Setup template context processor for global template variables self._setup_template_context() # Initialize databases self._init_databases() # Setup routes and SocketIO handlers self._setup_routes() self._setup_socketio_handlers() # Start database polling for real-time data self._start_database_polling() # Start log file tailing for /logs page self._start_log_tailing() # Start periodic cleanup self._start_cleanup_scheduler() # Start the dashboard snapshot refresher (moves the landing page's # aggregate queries off the request path) self._start_dashboard_refresher() self.logger.info("BotDataViewer initialized with Flask-SocketIO 5.x best practices") def _setup_logging(self): """Setup logging; file handler only when [Logging] log_file is set. Empty log_file (or missing [Logging] section) means console/journal only, matching the main bot. When a log file is configured, viewer logs go next to it as web_viewer.log (e.g. /var/log/meshcore-bot/web_viewer.log). """ from logging.handlers import RotatingFileHandler log_file = '' log_max_bytes = 5 * 1024 * 1024 log_backup_count = 3 if getattr(self, 'config', None) is not None and self.config.has_section('Logging'): log_file = self.config.get('Logging', 'log_file', fallback='').strip() try: log_max_bytes = self.config.getint('Logging', 'log_max_bytes', fallback=log_max_bytes) except (configparser.Error, ValueError, TypeError): pass try: log_backup_count = self.config.getint('Logging', 'log_backup_count', fallback=log_backup_count) except (configparser.Error, ValueError, TypeError): pass log_level_name = 'INFO' if getattr(self, 'config', None) is not None and self.config.has_section('Logging'): log_level_name = self.config.get( 'Logging', 'log_level', fallback='INFO', ).strip().upper() log_level = getattr(logging, log_level_name, logging.INFO) if not isinstance(log_level, int): log_level = logging.INFO # Get or create logger (don't use basicConfig as it may conflict with existing logging) self.logger = logging.getLogger('modern_web_viewer') self.logger.setLevel(log_level) # Remove existing handlers to avoid duplicates self.logger.handlers.clear() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') # Console handler (captured by journald under systemd) console_handler = logging.StreamHandler() # Keep DEBUG out of journald even when explicitly enabled for the # rotating diagnostic file, avoiding duplicate high-volume SD writes. console_handler.setLevel(max(log_level, logging.INFO)) console_handler.setFormatter(formatter) self.logger.addHandler(console_handler) # Prevent propagation to root logger to avoid duplicate messages self.logger.propagate = False if not log_file: self.logger.info("No log file specified, using console/journal logging only") return # Place viewer log beside the bot log (same directory as log_file) bot_log_path = Path(resolve_path(log_file, self._config_base)) viewer_log_path = bot_log_path.parent / 'web_viewer.log' try: viewer_log_path.parent.mkdir(parents=True, exist_ok=True) file_handler = RotatingFileHandler( str(viewer_log_path), maxBytes=log_max_bytes, backupCount=log_backup_count, encoding='utf-8', ) file_handler.setLevel(log_level) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) self.logger.info( "Web viewer logging initialized (file=%s, max=%s bytes, backups=%s)", viewer_log_path, log_max_bytes, log_backup_count, ) except (OSError, PermissionError) as e: self.logger.warning( "Could not open web viewer log file %s: %s. Using console/journal only.", viewer_log_path, e, ) def _load_config(self, config_path): """Load configuration from file""" config = configparser.ConfigParser() if os.path.exists(config_path): config.read(config_path) return config def _load_merged_config(self): """Load base config.ini plus its local overlay, mirroring core.py. core.py's ``_read_config_snapshot`` reads the base ``config.ini``, looks up ``[Bot] local_dir_path`` (fallback ``"local"``), resolves it relative to the bot root, and — if ``/config.ini`` exists — reads it into the *same* parser so it overlays the base values section-by-section/key-by-key. The web viewer needs the same merged view so settings edited via the local overlay show up here. """ base_parser = configparser.ConfigParser() if os.path.exists(self.config_path): base_parser.read(self.config_path, encoding="utf-8") base_sections = set(base_parser.sections()) local_dir_path_str = base_parser.get("Bot", "local_dir_path", fallback="local") self.local_dir = Path(resolve_path(local_dir_path_str, self._config_base)) self.local_config_path = str(self.local_dir / "config.ini") local_only = configparser.ConfigParser() if os.path.exists(self.local_config_path): local_only.read(self.local_config_path, encoding="utf-8") local_sections = set(local_only.sections()) if os.path.exists(self.local_config_path): base_parser.read(self.local_config_path, encoding="utf-8") self._base_sections = base_sections self._local_sections = local_sections return base_parser def _get_version_info(self) -> dict[str, str | None]: """Get version info for footer via centralized version resolver. Never raises.""" info = resolve_application_version() display = info.get("display") return { # 'display' is what the footer renders — same value !version reports, # so the two can't drift apart on dev or detached-tag checkouts. The # "unknown" sentinel is dropped so the footer omits the version # rather than advertising that we couldn't work it out. "display": None if display == "unknown" else display, "tag": info.get("tag"), "branch": info.get("branch"), "commit": info.get("commit"), "date": info.get("date"), } def _setup_template_context(self): """Setup template context processor to inject global variables""" version_info = self._version_info @self.app.context_processor def inject_template_vars(): """Inject variables available to all templates. Never raises so templates always render.""" try: try: greeter_enabled = self.config.getboolean('Greeter_Command', 'enabled', fallback=False) except (configparser.NoSectionError, configparser.NoOptionError, ValueError, TypeError): greeter_enabled = False try: feed_manager_enabled = self.config.getboolean('Feed_Manager', 'feed_manager_enabled', fallback=False) except (configparser.NoSectionError, configparser.NoOptionError, ValueError, TypeError): feed_manager_enabled = False try: bot_name = (self.config.get('Bot', 'bot_name', fallback='MeshCore Bot') or '').strip() or 'MeshCore Bot' except (configparser.NoSectionError, configparser.NoOptionError): bot_name = 'MeshCore Bot' try: radio_zombie = self.db_manager.get_metadata('bot.radio_zombie') == 'true' radio_zombie_since = self.db_manager.get_metadata('bot.radio_zombie_since') or None radio_offline = self.db_manager.get_metadata('bot.radio_offline') == 'true' radio_offline_since = self.db_manager.get_metadata('bot.radio_offline_since') or None bot_initializing = self.db_manager.get_metadata('bot.initializing') == 'true' except Exception: radio_zombie = False radio_zombie_since = None radio_offline = False radio_offline_since = None bot_initializing = False auth_enabled = bool(self.web_viewer_password) # Session bit only — missing password is not an admin session. is_admin = bool(session.get('authenticated_admin')) return { 'greeter_enabled': greeter_enabled, 'feed_manager_enabled': feed_manager_enabled, 'multibyte_monitor_enabled': self.multibyte_monitor_enabled, 'bot_name': bot_name, 'version_info': version_info, 'radio_zombie': radio_zombie, 'radio_zombie_since': radio_zombie_since, 'radio_offline': radio_offline, 'radio_offline_since': radio_offline_since, 'bot_initializing': bot_initializing, 'auth_enabled': auth_enabled, 'is_admin': is_admin, } except Exception as e: self.logger.exception("Template context processor failed: %s", e) return { 'greeter_enabled': False, 'feed_manager_enabled': False, 'multibyte_monitor_enabled': False, 'bot_name': 'MeshCore Bot', 'bot_initializing': False, 'version_info': version_info, 'radio_zombie': False, 'radio_zombie_since': None, 'radio_offline': False, 'radio_offline_since': None, 'auth_enabled': bool(getattr(self, 'web_viewer_password', '')), 'is_admin': False, } def _init_databases(self): """Initialize database connections""" try: # Initialize database manager for metadata access from modules.db_manager import DBManager # Create a minimal bot object for DBManager class MinimalBot: def __init__(self, logger, config, db_manager=None): self.logger = logger self.config = config self.db_manager = db_manager # Create DBManager first minimal_bot = MinimalBot(self.logger, self.config) self.db_manager = DBManager(minimal_bot, self.db_path) # Now set db_manager on the minimal bot for RepeaterManager minimal_bot.db_manager = self.db_manager # The viewer runs as a separate process, so it cannot call the bot's # MessageScheduler directly. MaintenanceRunner only needs this small # bot facade for manual database backups. self._maintenance_runner = MaintenanceRunner( minimal_bot, get_current_time=datetime.now, ) # The viewer only needs RepeaterManager for the manual geocode # endpoint, so defer its setup until that endpoint is actually used. self._repeater_manager_bot = minimal_bot self._repeater_manager_lock = threading.Lock() self.repeater_manager: RepeaterManager | None = None # RepeaterManager's constructor is what used to validate these at # startup. It is lazy now, so validate here: a missing migration is # a startup failure, not a mystery 500 from the geocode endpoint. validate_repeater_tables(self.db_manager, self.logger) # MeshGraph is only read by the two path-decoding routes, so build it # on first use rather than at startup. It is NOT optional for them: # without it decode_path_nodes() falls back to geographic-only # selection and disagrees with the bot's `path` command. self._mesh_graph_bot = minimal_bot self._mesh_graph_lock = threading.Lock() self.mesh_graph = None # Store database paths for direct connection self.db_path = self.db_path self.repeater_db_path = self.repeater_db_path self.logger.info("Database connections initialized") except Exception as e: self.logger.error(f"Failed to initialize databases: {e}") raise def _get_repeater_manager(self) -> RepeaterManager: """Lazily construct the manual-geocoding helper once.""" if self.repeater_manager is not None: return self.repeater_manager with self._repeater_manager_lock: if self.repeater_manager is None: self.repeater_manager = RepeaterManager(self._repeater_manager_bot) return self.repeater_manager def _get_mesh_graph(self): """Lazily load the read-only mesh graph used to disambiguate path prefixes. Returns None only if the graph cannot be loaded, in which case path decoding degrades to geographic-only selection rather than failing the request. Loaded with capture disabled: the bot process owns edge capture, so the viewer must not write edges or run a batch writer. """ if self.mesh_graph is not None: return self.mesh_graph with self._mesh_graph_lock: if self.mesh_graph is None: from modules.mesh_graph import MeshGraph try: graph = MeshGraph(self._mesh_graph_bot, capture=False) except Exception as e: self.logger.error( f"Mesh graph unavailable; path decoding will fall back to " f"geographic selection only: {e}" ) return None self._mesh_graph_bot.mesh_graph = graph self.mesh_graph = graph return self.mesh_graph def _configure_db_connection(self, conn: sqlite3.Connection) -> None: """Apply SQLite pragmas to a viewer connection. Delegates to DBManager, which owns the single implementation and the once-per-section WAL bookkeeping. Safe because this DBManager was built for self.db_path, which is the file every viewer connection opens — the journal-mode state it tracks belongs to that same database. """ self.db_manager._apply_sqlite_pragmas(conn, for_web_viewer=True) def _get_db_connection(self): """Get database connection - create new connection for each request to avoid threading issues""" try: conn = sqlite3.connect(self.db_path, timeout=60) conn.row_factory = sqlite3.Row self._configure_db_connection(conn) return conn except Exception as e: self.logger.error(f"Failed to create database connection: {e}") raise def _read_meta_settings( self, prefix: str, fields: list[str], defaults: dict[str, str] ) -> dict[str, str]: """Read ``.`` values from bot_metadata, unset ones as ``''``. A field that is unset or empty takes its value from ``defaults``. """ settings: dict[str, str] = {} for field in fields: val = self.db_manager.get_metadata(f'{prefix}.{field}') settings[field] = val if val is not None else '' for field, default in defaults.items(): if not settings.get(field): settings[field] = default return settings def _write_meta_settings(self, prefix: str, allowed: Any, data: dict[str, Any]) -> list[str]: """Store each allowed field present in ``data`` as ``.``; return those saved.""" saved = [] for field in allowed: if field in data: self.db_manager.set_metadata(f'{prefix}.{field}', str(data[field])) saved.append(field) return saved def _api_errors(self, message: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Route decorator: log ``": "`` and answer 500 with the error text. The response body is later rewritten to a generic message by set_security_headers, as for every 5xx JSON error. """ def decorate(view: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: try: return view(*args, **kwargs) except Exception as e: self.logger.error(f"{message}: {e}") return jsonify({'error': str(e)}), 500 return wrapper return decorate def _queue_operation(self, operation_type: str, payload: Any = None) -> int | None: """Queue a bot-side operation in ``channel_operations`` and return its row id. The bot's scheduler polls this table; callers poll /api/channel-operations/ for the result. ``payload`` is stored as JSON in ``payload_data`` when given. Errors propagate to the caller. """ with self.db_manager.connection() as conn: cursor = conn.cursor() if payload is None: cursor.execute( "INSERT INTO channel_operations (operation_type, status) VALUES (?, 'pending')", (operation_type,), ) else: cursor.execute( "INSERT INTO channel_operations (operation_type, payload_data, status) " "VALUES (?, ?, 'pending')", (operation_type, json.dumps(payload)), ) conn.commit() return cursor.lastrowid @contextmanager def _db_connection(self) -> Iterator[sqlite3.Connection]: """Yield ``_get_db_connection()`` and close it on exit. Unlike ``_with_db_connection``, this opens through ``_get_db_connection``, so it logs connect failures and honors tests that patch that method. """ conn = self._get_db_connection() try: yield conn finally: conn.close() @contextmanager def _with_db_connection(self): """Context manager that yields a configured connection and closes it on exit. Use this instead of _get_db_connection() in with-statements to avoid leaking file descriptors. """ conn = sqlite3.connect(self.db_path, timeout=60) conn.row_factory = sqlite3.Row self._configure_db_connection(conn) try: yield conn finally: conn.close() # Nodes in the neighbor tables are stored as full 32-byte public keys, so the # graph's highest resolution (3 bytes) is always available for edge identity. NEIGHBOR_PREFIX_HEX_CHARS = 6 def _resolve_path(self, path_input: str) -> dict[str, Any]: """Resolve a hex path to repeater names/locations for the mesh map. Thin wrapper over the shared engine (modules.path_inference.decode_path_nodes). Previously this duplicated the decode logic and crashed ("must be real number, not NoneType") on repeaters without coordinates; the shared engine guards that. """ if not hasattr(self, "db_manager") or not self.db_manager: return {"node_ids": [], "repeaters": [], "valid": False, "error": "Database manager not initialized"} from modules.path_inference import decode_path_nodes nodes = decode_path_nodes( path_input, None, config=self.config, db_manager=self.db_manager, logger=self.logger, mesh_graph=self._get_mesh_graph(), include_location=True, ) node_ids = [n["node_id"] for n in nodes] if not node_ids: return {"node_ids": [], "repeaters": [], "valid": False, "error": "No valid hex values found"} return {"node_ids": node_ids, "repeaters": nodes, "valid": True} def _setup_routes(self): """Setup all Flask routes - complete feature parity""" # Log full traceback for 500 errors so service logs show the real cause @self.app.errorhandler(500) def internal_error(e): self.logger.exception("Unhandled exception (500): %s", e) if request.path.startswith('/api/') or request.accept_mimetypes.best == 'application/json': return make_response(jsonify({'error': 'An internal error occurred — see server logs'}), 500) return make_response(render_template('error.html', error_code=500, error_title='Internal Server Error', error_message='Something went wrong on our end. The error has been logged.', ), 500) # Authentication middleware (BUG-001). # Fail closed when a password is configured: only the public allowlist # below is reachable without authenticated_admin. Everything else # (config, logs, radio, mutations, channel keys, sockets) stays admin. _EXEMPT_PATHS = frozenset([ '/login', '/logout', '/apple-touch-icon.png', '/favicon-32x32.png', '/favicon-16x16.png', '/site.webmanifest', '/favicon.ico', # Bot→viewer ingest uses X-Stream-Token, not the admin session. '/api/stream_data', ]) # Issue #240 public HTML surface (Realtime page renders; live socket stays admin). _PUBLIC_PAGE_PATHS = frozenset([ '/', '/realtime', '/contacts', '/mesh', ]) # Anonymous-safe GET APIs: mesh-visible / aggregate data only. No channel # keys, config, logs, backups, or private message firehose. _PUBLIC_API_GET_PATHS = frozenset([ '/api/health', '/api/banner-status', '/api/stats', '/api/dashboard/summary', '/api/dashboard/series', '/api/dashboard/top', '/api/dashboard/windows', '/api/contacts', '/api/contact-detail', '/api/mesh/nodes', '/api/mesh/edges', '/api/mesh/stats', ]) # Read-only POST helpers used by public Contacts / Mesh info panels. _PUBLIC_API_POST_PATHS = frozenset([ '/api/decode-path', '/api/mesh/resolve-path', ]) def _is_local_redirect(url: str) -> bool: # Browsers read '//host' and '/\host' as another origin, and strip # tabs/newlines before parsing, so '/\t/host' becomes '//host' too. if not url.startswith('/') or any(c in url for c in '\\\t\r\n'): return False if url.startswith('//'): return False parsed = urlparse(url) return not (parsed.scheme or parsed.netloc) def _normalize_request_path(path: str) -> str: if path != '/' and path.endswith('/'): path = path.rstrip('/') return path or '/' @self.app.before_request def create_csp_nonce(): """Create a per-response nonce for templates migrated off inline-script CSP.""" g.csp_nonce = secrets.token_urlsafe(24) @self.app.before_request def require_auth(): """Enforce admin auth except for the explicit public allowlist.""" if not self.web_viewer_password: return # Auth disabled — no password configured (legacy open mode) path = _normalize_request_path(request.path) if path in _EXEMPT_PATHS or path.startswith('/static/'): return if session.get('authenticated_admin'): return # HEAD and OPTIONS are answered by Flask from the GET route with no # body, so they are as safe as the GET they shadow. if request.method in ('GET', 'HEAD', 'OPTIONS') and ( path in _PUBLIC_PAGE_PATHS or path in _PUBLIC_API_GET_PATHS ): return if request.method in ('POST', 'OPTIONS') and path in _PUBLIC_API_POST_PATHS: return if path.startswith('/api/'): return make_response(jsonify({'error': 'Admin authentication required'}), 401) return redirect(url_for('login', next=path)) @self.app.before_request def csrf_protection(): """Reject cross-origin state-changing requests. For API endpoints (JSON), require the X-Requested-With header. Browsers block cross-origin custom headers without a CORS preflight, and our CORS policy restricts allowed origins — so the presence of this header proves the request is same-origin or from an allowed origin. Form-based POST to /login is exempt (uses session cookie + redirect). """ if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'): return if current_app.config.get('TESTING'): return # Skip CSRF in test mode if request.path == '/login': return # Login form uses traditional POST if request.headers.get('X-Requested-With'): return # Custom header present — same-origin or CORS-approved if request.path.startswith('/api/'): return make_response( jsonify({'error': 'Missing X-Requested-With header'}), 403 ) @self.app.after_request def set_security_headers(response): # Security headers response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' # Allow CDNs used by templates (base.html, login.html, mesh.html). # Without these hosts, browsers block external CSS/JS/fonts (not CSRF). # fonts.googleapis.com serves login.html's stylesheet and fonts.gstatic.com # the font files it references — both hosts are needed or the login page # silently falls back to system fonts. # The highest-risk admin screens have migrated their inline handlers # and authorize their remaining template scripts with a per-request # nonce. Other legacy screens retain unsafe-inline until their inline # scripts/handlers are migrated, rather than silently breaking them. nonce_hardened_endpoints = { 'index', 'feeds', 'config_page', 'radio', 'realtime', 'contacts', 'plugins_page', 'greeter', 'region_warnings_page', 'logs', 'multibyte_rollout', 'mesh', 'api_explorer', } if request.endpoint in nonce_hardened_endpoints: script_source = f"script-src 'self' 'nonce-{g.csp_nonce}' " else: script_source = "script-src 'self' 'unsafe-inline' " response.headers['Content-Security-Policy'] = ( "default-src 'self'; " + script_source + "https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; " "style-src 'self' 'unsafe-inline' " "https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com " "https://fonts.googleapis.com; " "img-src 'self' data: blob: https://*.tile.openstreetmap.org " "https://tiles.openfreemap.org " "https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " "connect-src 'self' ws: wss: https://tiles.openfreemap.org " "https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; " # MapLibre GL runs its renderer in a worker spawned from a blob: URL. # Without these it falls back to default-src 'self' and the dark # basemap fails to start. "worker-src 'self' blob:; " "child-src 'self' blob:; " "font-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com " "https://fonts.gstatic.com" ) # Sanitize error details from 5xx JSON responses to prevent info disclosure. # The full exception is already logged server-side; clients only need a # generic message. Preserve 'error' key presence so callers can detect # failure, but strip internal details (file paths, DB errors, etc.). if response.status_code >= 500 and response.content_type and 'json' in response.content_type: try: data = response.get_json(silent=True) if data and isinstance(data, dict) and 'error' in data: data['error'] = 'An internal error occurred' data.pop('traceback', None) response.set_data(json.dumps(data)) except Exception: pass # Don't break the response if sanitization fails return response @self.app.route('/login', methods=['GET', 'POST']) def login(): """Login page for admin authentication""" if not self.web_viewer_password: return redirect(url_for('index')) if request.method == 'POST': password = request.form.get('password', '') # Hash both sides so compare_digest always sees equal-length # digests (avoids TypeError / length short-circuit on ==). expected = hmac.new( b'web-viewer-login', self.web_viewer_password.encode('utf-8'), 'sha256', ).digest() provided = hmac.new( b'web-viewer-login', password.encode('utf-8'), 'sha256', ).digest() if hmac.compare_digest(provided, expected): session.clear() session['authenticated_admin'] = True # Ties this login's Socket.IO connections together so logout # can drop them (a socket keeps its connect-time session). session['admin_login_id'] = secrets.token_urlsafe(16) next_url = request.args.get('next', '/') if not _is_local_redirect(next_url): next_url = '/' return redirect(next_url) return render_template('login.html', error='Invalid password') return render_template('login.html') @self.app.route('/logout') def logout(): """Logout, clear session, and drop this login's live sockets""" login_id = session.get('admin_login_id') session.clear() if login_id: self._disconnect_login_sockets(login_id) return redirect(url_for('index')) @self.app.route('/') def index(): """Main dashboard. Server-side config reaches the page as a JSON data block rather than an inline script, so the dashboard's JavaScript can live in a static file that ``script-src 'self'`` already covers. """ database_size = None with suppress(OSError): database_size = os.path.getsize(self.db_path) return render_template( 'index.html', dashboard_boot={ 'database_size': database_size, 'snapshot_interval_seconds': self.dashboard_snapshot_interval, 'snapshot_enabled': self.dashboard_snapshot_enabled, }, ) @self.app.route('/realtime') def realtime(): """Real-time monitoring dashboard""" return render_template('realtime.html') @self.app.route('/logs') def logs(): """Live log viewer""" return render_template('logs.html') @self.app.route('/contacts') def contacts(): """Contacts page - unified contact management and tracking""" return render_template('contacts.html') @self.app.route('/cache') def cache(): """Legacy cache URL redirects to the database config panel.""" return redirect('/config#database') @self.app.route('/multibyte-rollout') def multibyte_rollout(): """Multibyte hash rollout analytics page""" if not self.multibyte_monitor_enabled: return abort(404) return render_template('multibyte_rollout.html') @self.app.route('/greeter') def greeter(): """Greeter management page""" return render_template('greeter.html') @self.app.route('/region-warnings') def region_warnings_page(): """Regional flood scope monitoring and warning settings.""" return render_template('region_warnings.html') @self.app.route('/feeds') def feeds(): """Feed management page""" return render_template('feeds.html') @self.app.route('/schedule') def schedule_page(): """Scheduled message management page""" return render_template('schedule.html') @self.app.route('/radio') def radio(): """Radio settings page. Passes config-governance flags so the Node Settings card doesn't offer device settings the bot itself manages from config.ini. """ auto_manage = 'false' bot_name = '' name_managed = False if self.config: auto_manage = self.config.get('Bot', 'auto_manage_contacts', fallback='device').lower() bot_name = (self.config.get('Bot', 'bot_name', fallback='') or '').strip() try: auto_update_name = self.config.getboolean('Bot', 'auto_update_device_name', fallback=True) except ValueError: auto_update_name = True name_managed = bool(bot_name) and auto_update_name return render_template( 'radio.html', auto_manage_contacts=auto_manage, device_name_managed=name_managed, bot_name=bot_name, ) @self.app.route('/config') def config_page(): """Bot configuration page""" return render_template( 'config.html', config_panels=sorted(CONFIG_PANELS, key=lambda panel: panel['order']), panel_categories=PANEL_CATEGORIES, ) # ── Plugins settings panel ─────────────────────────────────────────── @self.app.route('/plugins') def plugins_page(): """Plugin & command settings page.""" return render_template('plugins.html') @self.app.route('/api/plugins') def api_plugins_get(): """Return the settings view for every discovered command/service.""" try: # Re-read config from disk so the UI reflects external edits. self.config = self._load_merged_config() view = build_plugin_settings_view( self.config, logger=self.logger, local_commands_dir=str(self.local_dir / "commands"), local_services_dir=str(self.local_dir / "service_plugins"), ) return jsonify({'plugins': view}) except Exception: self.logger.exception("Error building plugin settings view") return jsonify({'error': 'Internal error — see server logs'}), 500 @self.app.route('/api/plugins//', methods=['POST']) def api_plugins_save(kind: str, name: str): """Validate and persist one plugin's settings, then queue a reload. Body: ``{"section": str, "enabled": bool, "values": {key: raw}}``. Validation mirrors the plugin's ``settings_schema`` server-side. """ try: data = request.get_json(silent=True) or {} # Locate the plugin entry so we have its schema + section. self.config = self._load_merged_config() view = build_plugin_settings_view( self.config, logger=self.logger, local_commands_dir=str(self.local_dir / "commands"), local_services_dir=str(self.local_dir / "service_plugins"), ) entry = next( (e for e in view if e['kind'] == kind and e['name'] == name), None, ) if entry is None: return jsonify({'success': False, 'error': 'Unknown plugin'}), 404 section = entry['section'] schema_by_key = {f['key']: f for f in entry['fields']} raw_values = data.get('values', {}) or {} errors: dict[str, str] = {} # Schema-typed keys are validated and routed to their (possibly # shared) target section; any other submitted key is written raw to # the plugin's own section (covers dynamic/legacy keys not in the # schema, so a partial schema never hides remaining settings). updates: dict[str, dict[str, str]] = {section: {}} deletes: dict[str, list[str]] = {} for key, val in raw_values.items(): field = schema_by_key.get(key) if field is not None: ok, coerced, err = validate_field(field, val) if not ok: errors[key] = err else: tsec = field.get('section') or section updates.setdefault(tsec, {})[key] = to_config_string(field, coerced) else: updates[section][str(key)] = '' if val is None else str(val) if errors: return jsonify({'success': False, 'errors': errors}), 400 # The enable toggle is always written to the plugin's own section. enabled = bool(data.get('enabled', entry['enabled'])) updates[section]['enabled'] = 'true' if enabled else 'false' submitted_dyn = data.get('dynamic_sections', {}) or {} for ds in entry.get('dynamic_sections', []): dsec = ds['section'] prefix = ds.get('key_prefix', '') or '' if dsec not in submitted_dyn: # Payload didn't include this editor's rows (partial or # scripted save) — leave the managed keys untouched # rather than treating absence as "delete everything". continue rows = submitted_dyn.get(dsec) or [] new_full: dict[str, str] = {} seen_full: set[str] = set() seen_disp: set[str] = set() for row in rows: rkey = (str(row.get('key', '')) or '').strip() rval = row.get('value', '') rval = '' if rval is None else str(rval) if not rkey: continue # skip blank rows key_err = _validate_dynamic_key(rkey) if key_err: return jsonify({'success': False, 'error': key_err}), 400 if rkey.lower() in seen_disp: return jsonify({'success': False, 'error': f'Duplicate key "{rkey}" in {ds["label"]}'}), 400 seen_disp.add(rkey.lower()) full = f"{prefix}{rkey}" new_full[full] = rval seen_full.add(full.lower()) # Merge into the target section (own section keeps schema fields). updates.setdefault(dsec, {}).update(new_full) # Delete existing managed keys that are no longer present. existing = self.config.items(dsec, raw=True) if self.config.has_section(dsec) else [] pl = prefix.lower() del_keys = [ k for k, _ in existing if (not prefix or k.lower().startswith(pl)) and k.lower() not in seen_full ] deletes.setdefault(dsec, []).extend(del_keys) # Repeating structured blocks (e.g. PacketCapture mqttN_*). Blocks # are renumbered contiguously from 1 (the service stops scanning at # the first missing index), validated per sub-schema, and unknown # sub-keys are passed through so they survive the save. submitted_blocks = data.get('repeating_blocks', {}) or {} for rb in entry.get('repeating_blocks', []): bid = rb['id'] if bid not in submitted_blocks: # Same defensive rule as dynamic sections: absent from # the payload means "don't touch", not "delete all". continue enabled_field = rb['enabled_field'] field_by_key = {f['key']: f for f in rb['fields']} written: set[str] = set() for i, block in enumerate(submitted_blocks.get(bid) or [], start=1): bvals = block.get('values', {}) or {} for k, val in bvals.items(): full = f"{bid}{i}_{k}" field = field_by_key.get(k) if field is not None: ok, coerced, err = validate_field(field, val) if not ok: return jsonify({'success': False, 'error': f'{rb["label"]} #{i}: {err}'}), 400 updates[section][full] = to_config_string(field, coerced) else: updates[section][full] = '' if val is None else str(val) written.add(full.lower()) en = f"{bid}{i}_{enabled_field}" updates[section][en] = 'true' if block.get('enabled', True) else 'false' written.add(en.lower()) # Delete any existing block keys (old higher indices / removed). brx = re.compile(rf"^{re.escape(bid)}\d+_", re.IGNORECASE) existing = self.config.items(section, raw=True) if self.config.has_section(section) else [] for k, _ in existing: if brx.match(k) and k.lower() not in written: deletes.setdefault(section, []).append(k) if section in self._local_sections: target_path = self.local_config_path elif section in self._base_sections: target_path = self.config_path else: # Brand-new section: local commands default into the local # overlay, everything else into the base config. target_path = ( self.local_config_path if entry.get('source') == 'local' else self.config_path ) if target_path == self.local_config_path and not os.path.exists(target_path): # update_ini_values() requires the target file to already # exist (it reads + backs up before writing) — local/config.ini # may not exist yet on a fresh install. os.makedirs(os.path.dirname(target_path), exist_ok=True) with open(target_path, 'w', encoding='utf-8') as f: f.write('') store = get_settings_store(self.config, target_path, self.db_manager) result = store.write_sections(updates, deletes) backup_path = result.get('backup_path', '') if isinstance(result, dict) else '' # A service's start/stop only takes effect on bot restart. restart_required = (kind == 'service' and enabled != entry['enabled']) # Queue a hot reload via the channel_operations table (the bot's # scheduler polls this — same pattern as radio reconnect). reload_queued = False try: self._queue_operation('config_reload') reload_queued = True except Exception: self.logger.exception("Failed to queue config reload") self.logger.info( "Plugin settings saved: %s [%s] (backup=%s)", name, section, os.path.basename(backup_path) if backup_path else 'none', ) return jsonify({ 'success': True, 'backup_path': backup_path, 'reload_queued': reload_queued, 'restart_required': restart_required, }) except IniValueError as exc: # A submitted key/value would corrupt the INI (newline, [ ], …). # Nothing was written; report it as a client error. return jsonify({'success': False, 'error': str(exc)}), 400 except Exception: self.logger.exception("Error saving plugin settings") return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 @self.app.route('/api/plugins/reload-status') def api_plugins_reload_status(): """Return the status of the most recent config_reload operation.""" try: rows = self.db_manager.execute_query( "SELECT status, result_data, processed_at FROM channel_operations " "WHERE operation_type = 'config_reload' ORDER BY id DESC LIMIT 1" ) if not rows: return jsonify({'status': None}) row = rows[0] return jsonify({ 'status': row.get('status'), 'result_data': row.get('result_data'), 'processed_at': row.get('processed_at'), }) except Exception: self.logger.exception("Error reading reload status") return jsonify({'status': None}), 500 @self.app.route('/api/config/notifications') def api_config_notifications_get(): """Return current notification settings from bot_metadata.""" settings = self._read_meta_settings( 'notif', [ 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_user', 'smtp_password', 'from_name', 'from_email', 'recipients', 'nightly_enabled', 'allow_local_smtp', ], defaults={'smtp_port': '587', 'smtp_security': 'starttls', 'nightly_enabled': 'false'}, ) return jsonify(settings) @self.app.route('/api/config/notifications', methods=['POST']) def api_config_notifications_post(): """Save notification settings to bot_metadata.""" data = request.get_json(silent=True) or {} allowed = { 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_user', 'smtp_password', 'from_name', 'from_email', 'recipients', 'nightly_enabled', 'allow_local_smtp', } saved = self._write_meta_settings('notif', allowed, data) self.logger.info(f"Notification settings updated: {', '.join(saved)}") return jsonify({'success': True, 'saved': saved}) @self.app.route('/api/config/notifications/test', methods=['POST']) def api_config_notifications_test(): """Send a test email using the saved SMTP settings.""" import smtplib import ssl as _ssl from email.message import EmailMessage def _get(key): return self.db_manager.get_metadata(f'notif.{key}') or '' smtp_host = _get('smtp_host') smtp_port = int(_get('smtp_port') or 587) smtp_security = _get('smtp_security') or 'starttls' smtp_user = _get('smtp_user') smtp_password = _get('smtp_password') from_name = _get('from_name') or 'MeshCore Bot' from_email = _get('from_email') recipients = [r.strip() for r in _get('recipients').split(',') if r.strip()] if not smtp_host: return jsonify({'error': 'SMTP host is not configured'}), 400 if not from_email: return jsonify({'error': 'Sender email is not configured'}), 400 if not recipients: return jsonify({'error': 'No recipients configured'}), 400 # Validate SMTP host for SSRF protection # allow_local_smtp=true permits private/internal SMTP hosts (e.g., local Postfix) allow_local_smtp = _get('allow_local_smtp').lower() == 'true' if not validate_external_url(f'http://{smtp_host}', allow_private=allow_local_smtp): if allow_local_smtp: return jsonify({'error': 'Invalid or unsafe SMTP host'}), 400 return jsonify({'error': 'Invalid or unsafe SMTP host (private/internal IP blocked)'}), 400 try: msg = EmailMessage() msg['Subject'] = 'MeshCore Bot — test email' msg['From'] = f'{from_name} <{from_email}>' msg['To'] = ', '.join(recipients) msg.set_content( 'This is a test email from MeshCore Bot.\n\n' 'If you received this, your SMTP settings are working correctly.\n' ) context = _ssl.create_default_context() if smtp_security == 'ssl': with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as s: if smtp_user and smtp_password: s.login(smtp_user, smtp_password) s.send_message(msg) else: with smtplib.SMTP(smtp_host, smtp_port) as s: if smtp_security == 'starttls': s.ehlo() s.starttls(context=context) s.ehlo() if smtp_user and smtp_password: s.login(smtp_user, smtp_password) s.send_message(msg) self.logger.info(f"Test email sent to {recipients}") return jsonify({'success': True, 'message': f'Test email sent to {", ".join(recipients)}'}) except Exception as e: self.logger.error(f"Test email failed: {e}") return jsonify({'error': str(e)}), 500 # ── Logging config ─────────────────────────────────────────────────── @self.app.route('/api/config/logging') def api_config_logging_get(): """Return log rotation settings from bot_metadata.""" settings = self._read_meta_settings( 'maint', ['log_max_bytes', 'log_backup_count'], defaults={'log_max_bytes': str(5 * 1024 * 1024), 'log_backup_count': '3'}, ) return jsonify(settings) @self.app.route('/api/config/logging', methods=['POST']) def api_config_logging_post(): """Save log rotation settings to bot_metadata.""" data = request.get_json(silent=True) or {} allowed = {'log_max_bytes', 'log_backup_count'} saved = self._write_meta_settings('maint', allowed, data) self.logger.info(f"Log rotation config updated: {', '.join(saved)}") return jsonify({'success': True, 'saved': saved}) # ── Maintenance config ─────────────────────────────────────────────── @self.app.route('/api/config/maintenance') def api_config_maintenance_get(): """Return DB backup and email hook settings from bot_metadata.""" settings = self._read_meta_settings( 'maint', [ 'db_backup_enabled', 'db_backup_schedule', 'db_backup_time', 'db_backup_retention_count', 'db_backup_dir', 'email_attach_log', ], defaults={ 'db_backup_enabled': 'false', 'db_backup_schedule': 'daily', 'db_backup_time': '02:00', 'db_backup_retention_count': '7', 'db_backup_dir': '/data/backups', 'email_attach_log': 'false', }, ) return jsonify(settings) @self.app.route('/api/config/maintenance', methods=['POST']) def api_config_maintenance_post(): """Save DB backup and email hook settings to bot_metadata.""" data = request.get_json(silent=True) or {} # Validate db_backup_dir before saving anything if 'db_backup_dir' in data: backup_dir = str(data['db_backup_dir']).strip() if backup_dir and not os.path.isdir(backup_dir): return jsonify({ 'error': f"Backup directory does not exist: {backup_dir}", }), 400 allowed = { 'db_backup_enabled', 'db_backup_schedule', 'db_backup_time', 'db_backup_retention_count', 'db_backup_dir', 'email_attach_log', } saved = self._write_meta_settings('maint', allowed, data) self.logger.info(f"Maintenance config updated: {', '.join(saved)}") return jsonify({'success': True, 'saved': saved}) # ── Zombie radio alert config ──────────────────────────────────────── @self.app.route('/api/config/zombie-alert') def api_config_zombie_alert_get() -> "Response": """Return zombie alert settings. Response includes both ``bot_metadata`` values (set via web UI) and ``config_ini`` values (read from config.ini) so the browser can show config.ini as the baseline defaults. """ meta: dict[str, str] = {} for key in ('zombie.alert_enabled', 'zombie.alert_email'): short = key.split('.', 1)[1] val = self.db_manager.get_metadata(key) meta[short] = val if isinstance(val, str) else '' if not meta.get('alert_enabled'): meta['alert_enabled'] = 'false' ini: dict[str, str] = { 'alert_enabled': ( 'true' if self.config.getboolean( 'Connection', 'radio_zombie_alert_enabled', fallback=self.config.getboolean('Bot', 'radio_zombie_alert_enabled', fallback=False), ) else 'false' ), 'alert_email': self.config.get( 'Connection', 'radio_zombie_alert_email', fallback=self.config.get('Bot', 'radio_zombie_alert_email', fallback=''), ), } return jsonify({'meta': meta, 'config_ini': ini}) @self.app.route('/api/config/zombie-alert', methods=['POST']) def api_config_zombie_alert_post() -> "Response": """Save zombie alert settings to bot_metadata. If ``write_to_config`` is ``true`` in the request body, the values are also written back to config.ini under ``[Connection]``. The config object in memory is updated immediately so the scheduler reads the new values without a restart. """ data = request.get_json(silent=True) or {} allowed = {'alert_enabled', 'alert_email'} saved = self._write_meta_settings('zombie', allowed, data) self.logger.info("Zombie alert config updated (metadata): %s", ', '.join(saved)) write_to_config = str(data.get('write_to_config', '')).lower() == 'true' config_saved = False if write_to_config: try: if not self.config.has_section('Connection'): self.config.add_section('Connection') ini_updates: dict[str, str] = {} if 'alert_enabled' in data: ini_updates['radio_zombie_alert_enabled'] = ( 'true' if str(data['alert_enabled']).lower() == 'true' else 'false' ) if 'alert_email' in data: ini_updates['radio_zombie_alert_email'] = str(data['alert_email']) if ini_updates: # Persist first: alert_email is free-form client JSON, so # a rejected value must not leave the in-memory config # holding something that was never written to disk. update_ini_values(self.config_path, {'Connection': ini_updates}) for ini_key, ini_val in ini_updates.items(): self.config.set('Connection', ini_key, ini_val) config_saved = True self.logger.info("Zombie alert settings written to config.ini") except IniValueError as exc: # Not an OSError — without this the route would 500. self.logger.warning("Rejected zombie alert config value: %s", exc) return jsonify({'success': False, 'error': str(exc)}), 400 except OSError as exc: self.logger.error("Failed to write zombie alert settings to config.ini: %s", exc) return jsonify({ 'success': False, 'error': 'Could not write config.ini — check file permissions', }), 500 return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) # ── Zombie recover ─────────────────────────────────────────────────── @self.app.route('/api/admin/zombie-recover', methods=['POST']) def api_admin_zombie_recover() -> "Response": """Clear zombie state so bot resumes processing after a radio power cycle. Clears the ``_radio_zombie_detected`` flag on the live bot object (if accessible) and removes the persisted flag from bot_metadata so the web-viewer banner disappears on the next page load. """ try: self.db_manager.set_metadata('bot.radio_zombie', 'false') self.db_manager.set_metadata('bot.radio_zombie_since', '') bot = getattr(self, 'bot', None) if bot is not None: bot._radio_zombie_detected = False bot._radio_fail_count = 0 bot._last_radio_probe = 0 # force probe on next cycle self.logger.info("Zombie state cleared via web UI recover action") return jsonify({'success': True, 'message': 'Zombie state cleared; bot will resume'}) except Exception: self.logger.exception("Error clearing zombie state") return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 # ── Radio debug config ─────────────────────────────────────────────── @self.app.route('/api/config/radio-debug') def api_config_radio_debug_get() -> "Response": """Return current radio debug logging setting. Response includes both ``bot_metadata`` value (set via web UI) and ``config_ini`` value (read from config.ini) so the browser can show which is the persistent baseline. """ meta_val = self.db_manager.get_metadata('radio.debug') meta_enabled = meta_val if isinstance(meta_val, str) else '' ini_enabled = ( 'true' if self.config.getboolean('Connection', 'radio_debug', fallback=False) else 'false' ) return jsonify({'meta': {'enabled': meta_enabled}, 'config_ini': {'enabled': ini_enabled}}) @self.app.route('/api/config/radio-debug', methods=['POST']) def api_config_radio_debug_post() -> "Response": """Save radio debug logging setting. Body fields: - ``enabled``: ``'true'`` or ``'false'`` - ``write_to_config``: ``'true'`` to also write ``[Connection] radio_debug`` to config.ini - ``reconnect``: ``'true'`` to queue a radio reconnect so the change takes effect immediately (the debug flag is only applied at connection time) """ try: data = request.get_json(silent=True) or {} enabled = str(data.get('enabled', 'false')).lower() == 'true' write_to_config = str(data.get('write_to_config', 'false')).lower() == 'true' do_reconnect = str(data.get('reconnect', 'false')).lower() == 'true' self.db_manager.set_metadata('radio.debug', 'true' if enabled else 'false') config_saved = False if write_to_config: try: if not self.config.has_section('Connection'): self.config.add_section('Connection') val = 'true' if enabled else 'false' self.config.set('Connection', 'radio_debug', val) update_ini_values(self.config_path, {'Connection': {'radio_debug': val}}) config_saved = True self.logger.info( "radio_debug=%s written to config.ini by web UI", val ) except OSError as exc: self.logger.error("Failed to write radio_debug to config.ini: %s", exc) return jsonify({ 'success': False, 'error': 'Could not write config.ini — check file permissions', }), 500 op_id = None if do_reconnect: op_id = self._queue_operation('radio_connect') self.logger.info("Radio reconnect queued (op_id=%s) to apply radio_debug=%s", op_id, enabled) return jsonify({'success': True, 'config_saved': config_saved, 'op_id': op_id}) except Exception as exc: self.logger.exception("Error saving radio debug config") return jsonify({'success': False, 'error': str(exc)}), 500 # ── Radio probe config ─────────────────────────────────────────────── @self.app.route('/api/config/radio-probe') def api_config_radio_probe_get() -> "Response": """Return radio probe settings.""" try: return jsonify({ 'probe_interval_seconds': self.db_manager.get_metadata('radio.probe_interval_seconds') or self.config.getint('Connection', 'radio_probe_interval_seconds', fallback=300), 'probe_fail_threshold': self.db_manager.get_metadata('radio.probe_fail_threshold') or self.config.getint('Connection', 'radio_probe_fail_threshold', fallback=3), }) except Exception as exc: self.logger.exception("Error getting radio probe config") return jsonify({'success': False, 'error': str(exc)}), 500 @self.app.route('/api/config/radio-probe', methods=['POST']) def api_config_radio_probe_post() -> "Response": """Save radio probe settings to bot_metadata.""" try: data = request.get_json(silent=True) or {} probe_interval = int(data.get('probe_interval_seconds', 300)) probe_fail_threshold = int(data.get('probe_fail_threshold', 3)) # Validate ranges if not (300 <= probe_interval <= 900): return jsonify({'success': False, 'error': 'probe_interval_seconds must be 300-900'}), 400 if not (1 <= probe_fail_threshold <= 10): return jsonify({'success': False, 'error': 'probe_fail_threshold must be 1-10'}), 400 saved = [] self.db_manager.set_metadata('radio.probe_interval_seconds', str(probe_interval)) saved.append('probe_interval_seconds') self.db_manager.set_metadata('radio.probe_fail_threshold', str(probe_fail_threshold)) saved.append('probe_fail_threshold') self.logger.info("Radio probe config updated (metadata): %s", ', '.join(saved)) # Optionally save to config.ini config_saved = False if data.get('save_to_config', False): try: self.config.set('Connection', 'radio_probe_interval_seconds', str(probe_interval)) self.config.set('Connection', 'radio_probe_fail_threshold', str(probe_fail_threshold)) update_ini_values(self.config_path, {'Connection': { 'radio_probe_interval_seconds': str(probe_interval), 'radio_probe_fail_threshold': str(probe_fail_threshold), }}) config_saved = True self.logger.info("Radio probe settings written to config.ini") except Exception as exc: self.logger.error("Failed to write radio probe settings to config.ini: %s", exc) return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) except Exception as exc: self.logger.exception("Error saving radio probe config") return jsonify({'success': False, 'error': str(exc)}), 500 # ── Radio offline alert config ─────────────────────────────────────── @self.app.route('/api/config/radio-offline-alert') def api_config_radio_offline_alert_get() -> "Response": """Return radio offline alert settings.""" try: return jsonify({ 'offline_threshold': self.db_manager.get_metadata('radio.offline_threshold') or self.config.getint('Connection', 'radio_offline_threshold', fallback=3), 'alert_enabled': self.db_manager.get_metadata('radio.offline_alert_enabled') == 'true' or self.config.getboolean('Connection', 'radio_offline_alert_enabled', fallback=False), 'alert_email': self.db_manager.get_metadata('radio.offline_alert_email') or self.config.get('Connection', 'radio_offline_alert_email', fallback=''), }) except Exception as exc: self.logger.exception("Error getting radio offline alert config") return jsonify({'success': False, 'error': str(exc)}), 500 @self.app.route('/api/config/radio-offline-alert', methods=['POST']) def api_config_radio_offline_alert_post() -> "Response": """Save radio offline alert settings to bot_metadata.""" try: data = request.get_json(silent=True) or {} offline_threshold = int(data.get('offline_threshold', 3)) alert_enabled = bool(data.get('alert_enabled', False)) alert_email = str(data.get('alert_email', '')).strip() # Validate ranges if not (1 <= offline_threshold <= 10): return jsonify({'success': False, 'error': 'offline_threshold must be 1-10'}), 400 saved = [] self.db_manager.set_metadata('radio.offline_threshold', str(offline_threshold)) saved.append('offline_threshold') self.db_manager.set_metadata('radio.offline_alert_enabled', 'true' if alert_enabled else 'false') saved.append('alert_enabled') self.db_manager.set_metadata('radio.offline_alert_email', alert_email) saved.append('alert_email') self.logger.info("Radio offline alert config updated (metadata): %s", ', '.join(saved)) # Optionally save to config.ini config_saved = False if data.get('save_to_config', False): try: enabled_val = 'true' if alert_enabled else 'false' offline_ini = { 'radio_offline_threshold': str(offline_threshold), 'radio_offline_alert_enabled': enabled_val, 'radio_offline_alert_email': alert_email, } # Persist first: alert_email is free-form client input, so # a rejected value must not leave the in-memory config # holding something that was never written to disk. update_ini_values(self.config_path, {'Connection': offline_ini}) for ini_key, ini_val in offline_ini.items(): self.config.set('Connection', ini_key, ini_val) config_saved = True self.logger.info("Radio offline alert settings written to config.ini") except Exception as exc: self.logger.error("Failed to write radio offline alert settings to config.ini: %s", exc) return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) except Exception as exc: self.logger.exception("Error saving radio offline alert config") return jsonify({'success': False, 'error': str(exc)}), 500 # ── Radio offline clear ────────────────────────────────────────────── @self.app.route('/api/admin/radio-offline-clear', methods=['POST']) def api_admin_radio_offline_clear() -> "Response": """Clear the radio-offline flag so the bot resumes outbound sends.""" try: self.db_manager.set_metadata('bot.radio_offline', 'false') self.db_manager.set_metadata('bot.radio_offline_since', '') bot = getattr(self, 'bot', None) if bot is not None: clear = getattr(bot, '_clear_radio_offline_state', None) if callable(clear): clear() else: bot._radio_offline = False bot._send_consecutive_failures = 0 self.logger.info("Radio-offline state cleared via web UI action") return jsonify({'success': True, 'message': 'Radio-offline flag cleared; sends will resume'}) except Exception: self.logger.exception("Error clearing radio-offline state") return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 # ── Maintenance status ─────────────────────────────────────────────── @self.app.route('/api/maintenance/backup_now', methods=['POST']) def api_maintenance_backup_now(): """Trigger an immediate DB backup outside the normal schedule.""" try: runner = getattr(self, '_maintenance_runner', None) if runner is None: return jsonify({'success': False, 'error': 'Maintenance runner not available'}), 503 runner.run_db_backup() # Read the outcome written by MaintenanceRunner. path = self.db_manager.get_metadata('maint.status.db_backup_path') or '' outcome = self.db_manager.get_metadata('maint.status.db_backup_outcome') or '' if outcome.startswith('error'): return jsonify({'success': False, 'error': outcome}), 500 return jsonify({'success': True, 'path': path, 'outcome': outcome}) except Exception as e: self.logger.error(f"Error in backup_now: {e}", exc_info=True) return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/maintenance/restore', methods=['POST']) def api_maintenance_restore(): """Stage a verified DB backup for the next full service restart. Body: {"db_file": "/absolute/path/to/backup.db"} The active DB is never modified by this request. Normal bot startup applies the sibling pending file before any DB connection is opened. """ try: data = request.get_json(silent=True) or {} db_file = str(data.get('db_file', '')).strip() if not db_file: return jsonify({'error': 'db_file is required'}), 400 # Validate path is within the configured backup directory backup_dir_str = self.db_manager.get_metadata('maint.db_backup_dir') or '' if not backup_dir_str or not os.path.isdir(backup_dir_str): return jsonify({'error': 'No valid backup directory configured'}), 400 # Validate path is within the configured backup directory # First check for dangerous system paths, then check if path is within backup dir backup_dir = Path(backup_dir_str).resolve() src = Path(db_file).resolve() # Check for dangerous system paths first (returns 400) target_str = str(src).lower() dangerous_prefixes = [ '/etc', '/private/etc', '/sys', '/proc', '/dev', '/bin', '/sbin', '/boot', ] if any(target_str.startswith(prefix) for prefix in dangerous_prefixes): return jsonify({'error': 'Access to system directory denied'}), 400 # Check if path is within the backup directory (prevents traversal) try: src.relative_to(backup_dir) except ValueError: # Path is outside backup directory - return 403 return jsonify({'error': 'Restore path must be within the configured backup directory'}), 403 if not src.exists(): return jsonify({'error': f'File not found: {db_file}'}), 400 try: max_restore_bytes = self.config.getint( 'Web_Viewer', 'restore_max_bytes', fallback=DEFAULT_MAX_RESTORE_BYTES, ) pending = stage_database_restore( src, self.db_path, max_bytes=max_restore_bytes, ) except (DatabaseRestoreError, OSError, ValueError) as exc: self.logger.warning("Database restore staging rejected for %s: %s", src, exc) return jsonify({'error': str(exc)}), 400 self.logger.warning( "Database restore staged from %s at %s; a full service restart is required", src, pending, ) return jsonify({ 'success': True, 'staged_from': db_file, 'pending_path': str(pending), 'active_db': self.db_path, 'requires_restart': True, 'warning': ( 'Restore verified and staged. Restart the complete MeshCore Bot service ' 'to apply it before any database writer starts.' ), }), 202 except Exception as e: self.logger.error(f"Error in restore: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/maintenance/list_backups') @self._api_errors('Error listing backups') def api_maintenance_list_backups(): """List available backup files from the configured backup directory.""" backup_dir_str = self.db_manager.get_metadata('maint.db_backup_dir') or '' if not backup_dir_str or not os.path.isdir(backup_dir_str): return jsonify({'backups': []}) backup_dir = Path(backup_dir_str) db_stem = Path(self.db_path).stem files = sorted( backup_dir.glob(f'{db_stem}_*.db'), key=lambda p: p.stat().st_mtime, reverse=True, ) backups = [ { 'path': str(f), 'name': f.name, 'size_mb': round(f.stat().st_size / 1_048_576, 2), 'mtime': f.stat().st_mtime, } for f in files ] return jsonify({'backups': backups}) @self.app.route('/api/maintenance/purge', methods=['POST']) def api_maintenance_purge(): """Delete aged rows from time-series tables. Body: {"keep_days": |"all", "tables": [, ...] optional} If ``tables`` is omitted or null, all purgeable tables are included. If ``tables`` is a non-empty list, only those table names are purged (each must be one of the known purgeable tables). An empty ``tables`` list is invalid (400). Valid keep_days values: "all", 1, 7, 14, 30, 60, 90 Returns: {"deleted": {: , ...}} — only tables that were purged """ _VALID_KEEP_DAYS = {"all", 1, 7, 14, 30, 60, 90} # (table, timestamp column) — some tables are created lazily. _purge_ops = [ ('packet_stream', 'timestamp'), ('message_stats', 'timestamp'), ('complete_contact_tracking', 'last_heard'), ('purging_log', 'timestamp'), ('mesh_connections', 'last_seen'), ('daily_stats', 'date'), ] _PURGEABLE = {table for table, _ in _purge_ops} try: data = request.get_json(silent=True) or {} raw = data.get('keep_days', 'all') if raw == 'all' or raw == 'All': keep_days: str | int = 'all' else: try: keep_days = int(raw) except (TypeError, ValueError): return jsonify({'error': f'Invalid keep_days: {raw!r}'}), 400 if keep_days not in _VALID_KEEP_DAYS: return jsonify({'error': f'keep_days must be one of {sorted(v for v in _VALID_KEEP_DAYS if isinstance(v, int))} or "all"'}), 400 tables_filter: list[str] | None = None if 'tables' in data: tf = data.get('tables') if tf is None: tables_filter = None elif not isinstance(tf, list): return jsonify({'error': 'tables must be a list of table names or null'}), 400 elif len(tf) == 0: return jsonify({'error': 'tables cannot be empty; omit tables to purge all tables'}), 400 else: bad = [x for x in tf if not isinstance(x, str) or x not in _PURGEABLE] if bad: return jsonify({ 'error': f'Invalid table name(s): {bad!r}; allowed: {sorted(_PURGEABLE)}', }), 400 seen: set[str] = set() tables_filter = [] for name in tf: if name not in seen: seen.add(name) tables_filter.append(name) deleted: dict[str, int] = {} if keep_days == 'all': # Nothing to delete — keep everything return jsonify({'deleted': deleted}) assert isinstance(keep_days, int) from datetime import timedelta as _timedelta cutoff_unix = time.time() - keep_days * 86400 _cutoff_dt = datetime.now(timezone.utc) - _timedelta(days=keep_days) cutoff_iso = _cutoff_dt.strftime('%Y-%m-%d %H:%M:%S') cutoff_date = _cutoff_dt.strftime('%Y-%m-%d') _params_for = { 'packet_stream': (cutoff_unix,), 'message_stats': (int(cutoff_unix),), 'complete_contact_tracking': (cutoff_iso,), 'purging_log': (cutoff_iso,), 'mesh_connections': (cutoff_iso,), 'daily_stats': (cutoff_date,), } if tables_filter is None: ops_to_run = [ (table, column, _params_for[table][0]) for table, column in _purge_ops ] else: want = set(tables_filter) ops_to_run = [ (table, column, _params_for[table][0]) for table, column in _purge_ops if table in want ] for table, column, cutoff in ops_to_run: try: deleted[table] = ( self.db_manager.delete_timestamp_rows_in_chunks( table, column, cutoff, progress_label=f'manual {table.replace("_", " ")} purge', ) ) except Exception: deleted[table] = 0 total = sum(deleted.values()) self.logger.info( f"Purge completed: keep_days={keep_days}, tables={tables_filter!r}, " f"total_deleted={total}, by_table={deleted}" ) return jsonify({'deleted': deleted}) except Exception as e: self.logger.error(f"Error in purge: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/maintenance/status') def api_maintenance_status(): """Return last-run times and outcomes for all maintenance jobs.""" status_keys = [ 'maint.status.data_retention_ran_at', 'maint.status.data_retention_outcome', 'maint.status.nightly_email_ran_at', 'maint.status.nightly_email_outcome', 'maint.status.db_backup_ran_at', 'maint.status.db_backup_outcome', 'maint.status.db_backup_path', 'maint.status.log_rotation_applied_at', ] result = {} for k in status_keys: short = k[len('maint.status.'):] val = self.db_manager.get_metadata(k) result[short] = val if val is not None else '' return jsonify(result) @self.app.route('/api-explorer') def api_explorer(): """API Explorer — browse all endpoints with curl examples.""" return render_template('api_explorer.html') @self.app.route('/admin/config') def admin_config(): """Resolved config viewer — shows effective config.ini values with sensitive fields redacted.""" sections = config_to_redacted_sections(self.config) return render_template('admin_config.html', sections=sections, config_path=self.config_path) @self.app.route('/mesh') def mesh(): """Mesh graph visualization page""" prefix_hex_chars = self.config.getint('Bot', 'prefix_bytes', fallback=1) * 2 if prefix_hex_chars <= 0: prefix_hex_chars = 2 return render_template( 'mesh.html', prefix_hex_chars=prefix_hex_chars ) # Favicon routes @self.app.route('/apple-touch-icon.png') def apple_touch_icon(): """Apple touch icon""" return send_from_directory( os.path.join(os.path.dirname(__file__), 'static', 'ico'), 'apple-touch-icon.png' ) @self.app.route('/favicon-32x32.png') def favicon_32x32(): """32x32 favicon""" return send_from_directory( os.path.join(os.path.dirname(__file__), 'static', 'ico'), 'favicon-32x32.png' ) @self.app.route('/favicon-16x16.png') def favicon_16x16(): """16x16 favicon""" return send_from_directory( os.path.join(os.path.dirname(__file__), 'static', 'ico'), 'favicon-16x16.png' ) @self.app.route('/site.webmanifest') def site_webmanifest(): """Web manifest file""" return send_from_directory( os.path.join(os.path.dirname(__file__), 'static', 'ico'), 'site.webmanifest', mimetype='application/manifest+json' ) @self.app.route('/favicon.ico') def favicon(): """Default favicon""" return send_from_directory( os.path.join(os.path.dirname(__file__), 'static', 'ico'), 'favicon.ico' ) # API Routes @self.app.route('/api/health') def api_health(): """Health check endpoint""" # Get bot uptime bot_uptime = self._get_bot_uptime() with self._clients_lock: client_count = len(self.connected_clients) radio_zombie = self.db_manager.get_metadata('bot.radio_zombie') == 'true' radio_zombie_since = self.db_manager.get_metadata('bot.radio_zombie_since') or None return jsonify({ 'status': 'degraded' if radio_zombie else 'healthy', 'connected_clients': client_count, 'max_clients': self.max_clients, 'timestamp': time.time(), 'bot_uptime': bot_uptime, 'version': 'modern_2.0', 'radio_zombie': radio_zombie, 'radio_zombie_since': radio_zombie_since, }) @self.app.route('/api/banner-status') def api_banner_status(): """Return current banner states for live JS polling.""" try: radio_zombie = self.db_manager.get_metadata('bot.radio_zombie') == 'true' radio_zombie_since = self.db_manager.get_metadata('bot.radio_zombie_since') or None radio_offline = self.db_manager.get_metadata('bot.radio_offline') == 'true' radio_offline_since = self.db_manager.get_metadata('bot.radio_offline_since') or None bot_initializing = self.db_manager.get_metadata('bot.initializing') == 'true' except Exception: radio_zombie = False radio_zombie_since = None radio_offline = False radio_offline_since = None bot_initializing = False return jsonify({ 'radio_zombie': radio_zombie, 'radio_zombie_since': radio_zombie_since, 'radio_offline': radio_offline, 'radio_offline_since': radio_offline_since, 'bot_initializing': bot_initializing, }) @self.app.route('/api/system-health') def api_system_health(): """Get comprehensive system health status from database""" try: # Read health data from database (consistent with how other data is accessed) health_data = self.db_manager.get_system_health() if not health_data: # If no health data in database, return minimal status return jsonify({ 'status': 'unknown', 'timestamp': time.time(), 'message': 'Health data not available yet', 'components': {} }) # Update timestamp to reflect current time (data may be slightly stale) health_data['timestamp'] = time.time() # Recalculate uptime if start_time is available start_time = self.db_manager.get_bot_start_time() if start_time: health_data['uptime_seconds'] = time.time() - start_time # Inject zombie radio state from shared metadata radio_zombie = self.db_manager.get_metadata('bot.radio_zombie') == 'true' health_data['radio_zombie'] = radio_zombie health_data['radio_zombie_since'] = ( self.db_manager.get_metadata('bot.radio_zombie_since') or None ) if radio_zombie: health_data['status'] = 'degraded' return jsonify(health_data) except Exception as e: self.logger.error(f"Error getting system health: {e}") import traceback self.logger.debug(traceback.format_exc()) return jsonify({ 'error': str(e), 'status': 'error' }), 500 @self.app.route('/api/stats') @self._api_errors('Error getting stats') def api_stats(): """Deprecated: whole-database statistics in one payload. Superseded by /api/dashboard/summary (snapshot-backed) and /api/dashboard/top (one narrow query per leaderboard). Retained with every key name intact for external consumers, and scheduled for removal at the next major version. """ # Get optional time window parameters for analytics top_users_window = request.args.get('top_users_window', 'all') top_commands_window = request.args.get('top_commands_window', 'all') top_paths_window = request.args.get('top_paths_window', 'all') top_channels_window = request.args.get('top_channels_window', 'all') stats = self._get_database_stats( top_users_window=top_users_window, top_commands_window=top_commands_window, top_paths_window=top_paths_window, top_channels_window=top_channels_window ) stats['deprecated'] = True response = jsonify(stats) response.headers['Deprecation'] = 'true' response.headers['Sunset'] = STATS_ENDPOINT_SUNSET response.headers['Link'] = '; rel="successor-version"' return response @self.app.route('/api/dashboard/summary') @self._api_errors('Error reading dashboard summary') def api_dashboard_summary(): """Snapshot-backed dashboard payload — one row read, no aggregation. Sparkline series are folded in so first paint costs two requests (/api/health plus this) instead of the six the old page made. """ with self._with_db_connection() as conn: payload = self.dashboard_stats.read_summary(conn) if payload is None: # No snapshot yet: the refresher runs a couple of seconds # after startup, so tell the client to retry rather than # recomputing everything on the request path. response = jsonify({ 'error': 'Dashboard snapshot not generated yet', 'pending': True, }) response.status_code = 503 response.headers['Retry-After'] = '5' return response # ETags.contains() wants the bare tag; the quotes belong only in # the header itself. tag = str(payload['generated_at']) if request.if_none_match.contains(tag): response = self.app.response_class(status=304) else: response = jsonify(payload) response.headers['ETag'] = f'"{tag}"' response.headers['Cache-Control'] = 'private, max-age=15' return response @self.app.route('/api/dashboard/series') def api_dashboard_series(): """Full-history points for one metric, for the expand-chart interaction.""" metric = request.args.get('metric', 'messages') if metric not in SERIES_METRICS: return jsonify({'error': f'Unknown metric: {metric}'}), 400 try: days = int(request.args.get('days', 30)) except (TypeError, ValueError): days = 30 try: with self._with_db_connection() as conn: payload = self.dashboard_stats.read_series(conn, metric, days) tag = f'{metric}:{days}:{payload.pop("etag_source", None)}' if request.if_none_match.contains(tag): response = self.app.response_class(status=304) else: response = jsonify(payload) response.headers['ETag'] = f'"{tag}"' response.headers['Cache-Control'] = 'private, max-age=300' return response except Exception as e: self.logger.error(f"Error reading dashboard series: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/dashboard/top') def api_dashboard_top(): """One leaderboard, one narrow query — replaces four whole-payload reads.""" kind = request.args.get('kind', 'users') if kind not in TOP_KINDS: return jsonify({'error': f'Unknown kind: {kind}'}), 400 window = request.args.get('window', 'all') if window not in ('24h', '7d', '30d', '90d', 'all'): window = 'all' try: limit = int(request.args.get('limit', 15)) except (TypeError, ValueError): limit = 15 try: with self._with_db_connection() as conn: payload = self.dashboard_stats.read_top(conn, kind, window, limit) response = jsonify(payload) response.headers['Cache-Control'] = 'private, max-age=30' return response except Exception as e: self.logger.error(f"Error reading dashboard top {kind}: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/dashboard/windows') @self._api_errors('Error deriving dashboard windows') def api_dashboard_windows(): """Selector options derived from retention, so no label overclaims.""" response = jsonify(self.dashboard_stats.derive_windows()) response.headers['Cache-Control'] = 'private, max-age=300' return response @self.app.route('/api/dashboard/refresh', methods=['POST']) def api_dashboard_refresh(): """Force a snapshot refresh. Used by the health strip's manual control.""" try: with closing(self._dashboard_connection()) as conn: result = self.dashboard_stats.refresh(conn) return jsonify({'success': True, **result}) except Exception as e: self.logger.error(f"Error refreshing dashboard snapshot: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/stats/rate_limiters') @self._api_errors('Error getting rate limiter stats') def api_rate_limiter_stats(): """Return current rate limiter statistics from the running bot.""" bot = getattr(self, 'bot', None) stats: dict[str, Any] = {} if bot is None: return jsonify(stats) if hasattr(bot, 'rate_limiter') and bot.rate_limiter: stats['message'] = bot.rate_limiter.get_stats() if hasattr(bot, 'bot_tx_rate_limiter') and bot.bot_tx_rate_limiter: stats['tx'] = bot.bot_tx_rate_limiter.get_stats() if hasattr(bot, 'per_user_rate_limiter') and bot.per_user_rate_limiter: rl = bot.per_user_rate_limiter stats['per_user'] = { 'seconds': rl.seconds, 'tracked_users': len(rl._last_send), 'max_entries': rl.max_entries, } if hasattr(bot, 'channel_rate_limiter') and bot.channel_rate_limiter: stats['channels'] = bot.channel_rate_limiter.get_stats() if hasattr(bot, 'nominatim_rate_limiter') and bot.nominatim_rate_limiter: stats['nominatim'] = bot.nominatim_rate_limiter.get_stats() return jsonify(stats) @self.app.route('/api/connected_clients') @self._api_errors('Error getting connected clients') def api_connected_clients(): """Return list of currently connected web viewer clients.""" with self._clients_lock: clients = [ { 'client_id': cid[:8] + '…' if len(cid) > 8 else cid, 'connected_at': info.get('connected_at'), 'last_activity': info.get('last_activity'), } for cid, info in self.connected_clients.items() ] return jsonify(clients) @self.app.route('/api/contacts') @self._api_errors('Error getting contacts') def api_contacts(): """Get filtered contact data, optionally paginated for the interactive list.""" since = request.args.get('since', '30d') if since not in ('24h', '7d', '30d', '90d', 'all'): since = '30d' paginate = 'page' in request.args or 'page_size' in request.args page = None page_size = None if paginate: try: page = max(1, int(request.args.get('page', '1'))) except (TypeError, ValueError): page = 1 try: page_size = max(1, min(200, int(request.args.get('page_size', '100')))) except (TypeError, ValueError): page_size = 100 search = request.args.get('search', '').strip()[:100] path_bytes = request.args.get('path_bytes', '').strip() device_role = request.args.get('device_role', '').strip() hop_filter = request.args.get('hop_filter', '').strip() location_filter = request.args.get('location_filter', '').strip() starred = request.args.get('starred', '').strip() sort = request.args.get('sort', 'last_seen') direction = request.args.get('direction', 'desc').lower() contacts = self._get_tracking_data( since=since, page=page, page_size=page_size, search=search, path_bytes=path_bytes, device_role=device_role, hop_filter=hop_filter, location_filter=location_filter, starred=starred, sort=sort, direction=direction, ) return jsonify(contacts) @self.app.route('/api/contact-detail') @self._api_errors('Error getting contact detail') def api_contact_detail(): """On-demand per-contact detail (recent advert paths + advertisement data) for the contacts UI modals. These are intentionally excluded from the /api/contacts list payload. Query param: user_id (the contact's public key).""" public_key = request.args.get('user_id', '').strip() if not public_key: return jsonify({'error': 'user_id is required'}), 400 return jsonify(self._get_contact_detail(public_key)) @self.app.route('/api/multibyte-rollout') def api_multibyte_rollout(): """Multibyte hash rollout analytics. since=24h|7d|30d|90d|all, node_type=all|repeater|roomserver.""" if not self.multibyte_monitor_enabled: return abort(404) try: since = request.args.get('since', '30d') if since not in ('24h', '7d', '30d', '90d', 'all'): since = '30d' node_type = request.args.get('node_type', 'all') if node_type not in ('all', 'repeater', 'roomserver'): node_type = 'all' data = self._get_multibyte_rollout_data(since=since, node_type=node_type) return jsonify(data) except Exception as e: self.logger.error(f"Error getting multibyte rollout data: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/cache') @self._api_errors('Error getting cache') def api_cache(): """Get cache data""" cache_data = self._get_cache_data() return jsonify(cache_data) @self.app.route('/api/database') @self._api_errors('Error getting database info') def api_database(): """Get database information""" db_info = self._get_database_info() return jsonify(db_info) @self.app.route('/api/optimize-database', methods=['POST']) def api_optimize_database(): """Optimize database using VACUUM, ANALYZE, and REINDEX""" try: result = self._optimize_database() return jsonify(result) except Exception as e: self.logger.error(f"Error optimizing database: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/mesh/nodes') def api_mesh_nodes(): """Get all repeater nodes with locations and metadata. Prefix length from query param or [Bot] prefix_bytes.""" try: prefix_hex_chars = request.args.get('prefix_hex_chars', type=int) days = request.args.get('days', type=int) if prefix_hex_chars not in (2, 4, 6): prefix_hex_chars = self.config.getint('Bot', 'prefix_bytes', fallback=1) * 2 if prefix_hex_chars <= 0: prefix_hex_chars = 2 with self._db_connection() as conn: cursor = conn.cursor() query = f''' SELECT public_key, SUBSTR(public_key, 1, {prefix_hex_chars}) as prefix, name, latitude, longitude, role, is_starred, last_heard, last_advert_timestamp FROM complete_contact_tracking WHERE role IN ('repeater', 'roomserver') AND latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 ''' params = [] if days is not None: query += ''' AND COALESCE(NULLIF(last_heard, ''), last_advert_timestamp) >= datetime("now", "-" || ? || " days") ''' params.append(days) query += ' ORDER BY name' cursor.execute(query, params) rows = cursor.fetchall() nodes = [] for row in rows: nodes.append({ 'public_key': row['public_key'], 'prefix': row['prefix'].lower(), 'name': row['name'] or f"Node {row['prefix']}", 'latitude': float(row['latitude']), 'longitude': float(row['longitude']), 'role': row['role'], 'is_starred': bool(row['is_starred']), 'last_heard': row['last_heard'], 'last_advert_timestamp': row['last_advert_timestamp'] }) return jsonify({'nodes': nodes}) except Exception as e: self.logger.error(f"Error getting mesh nodes: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/mesh/edges') def api_mesh_edges(): """Get all graph edges with metadata. evidence=multibyte derives edges purely from unique multi-byte path observations (observed_paths, bytes_per_hop >= 2), bypassing the mesh_connections merge heuristics that single-byte evidence feeds into. evidence=neighbors derives edges purely from confirmed zero-hop discovery (neighbor_links) — full public keys on both ends plus a measured SNR, the strongest evidence class available. """ try: # Get optional query parameters min_observations = request.args.get('min_observations', type=int) days = request.args.get('days', type=int) min_distance = request.args.get('min_distance', type=float) max_distance = request.args.get('max_distance', type=float) evidence = request.args.get('evidence', 'all') force_refresh = request.args.get('refresh') == '1' if evidence == 'multibyte': edges, prefix_hex_chars = self._derive_multibyte_evidence_graph( days=days, min_observations=min_observations, force_refresh=force_refresh, ) return jsonify({ 'edges': edges, 'prefix_hex_chars': prefix_hex_chars, 'evidence': 'multibyte', }) if evidence == 'neighbors': edges, prefix_hex_chars = self._derive_neighbor_evidence_graph( days=days, min_observations=min_observations, ) return jsonify({ 'edges': edges, 'prefix_hex_chars': prefix_hex_chars, 'evidence': 'neighbors', }) # Combined view: mesh_connections cannot record *why* an edge # exists, so re-derive the strongest label from neighbor_links. # Same window as the edges themselves, so stale evidence cannot # claim a recent edge is a current direct neighbor. neighbor_keys = self._neighbor_evidence_edge_keys(days=days) with self._db_connection() as conn: cursor = conn.cursor() # Edge windows control visibility, but node identity must retain # the lifetime graph's prefix resolution. Otherwise an older # multi-byte edge disappearing from the window can collapse # distinct nodes onto one shorter prefix in the browser. cursor.execute( ''' SELECT COALESCE( MAX( CASE WHEN LENGTH(from_prefix) > LENGTH(to_prefix) THEN LENGTH(from_prefix) ELSE LENGTH(to_prefix) END ), 2 ) AS prefix_hex_chars FROM mesh_connections ''' ) prefix_hex_chars = cursor.fetchone()['prefix_hex_chars'] query = ''' SELECT from_prefix, to_prefix, from_public_key, to_public_key, observation_count, first_seen, last_seen, avg_hop_position, geographic_distance FROM mesh_connections WHERE 1=1 ''' params = [] if min_observations is not None: query += ' AND observation_count >= ?' params.append(min_observations) if days is not None: query += ' AND last_seen >= datetime("now", "-" || ? || " days")' params.append(days) if min_distance is not None: query += ' AND geographic_distance >= ?' params.append(min_distance) if max_distance is not None: query += ' AND geographic_distance <= ?' params.append(max_distance) query += ' ORDER BY last_seen DESC' cursor.execute(query, params) rows = cursor.fetchall() edges = [] for row in rows: fp, tp = row['from_prefix'], row['to_prefix'] prefix_hex_chars = max(prefix_hex_chars, len(fp) if fp else 0, len(tp) if tp else 0) # Edges keyed at 4+ hex chars were necessarily created (or promoted) # by a multi-byte path observation; 2-char keys carry only ambiguous # single-byte evidence. is_multibyte = bool(fp) and bool(tp) and len(fp) >= 4 and len(tp) >= 4 from_lower = fp.lower() if fp else '' to_lower = tp.lower() if tp else '' from_key = (row['from_public_key'] or '').lower() to_key = (row['to_public_key'] or '').lower() if ( (from_lower, to_lower) in neighbor_keys.prefixes or (from_key and to_key and (from_key, to_key) in neighbor_keys.public_keys) ): edge_evidence = 'neighbors' elif is_multibyte: edge_evidence = 'multibyte' else: edge_evidence = 'singlebyte' edges.append({ 'from_prefix': from_lower, 'to_prefix': to_lower, 'from_public_key': row['from_public_key'], 'to_public_key': row['to_public_key'], 'observation_count': row['observation_count'], 'first_seen': row['first_seen'], 'last_seen': row['last_seen'], 'avg_hop_position': row['avg_hop_position'], 'geographic_distance': row['geographic_distance'], 'evidence': edge_evidence }) return jsonify({'edges': edges, 'prefix_hex_chars': prefix_hex_chars or 2}) except Exception as e: self.logger.error(f"Error getting mesh edges: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/mesh/stats') def api_mesh_stats(): """Get graph statistics""" conn = None try: conn = self._get_db_connection() cursor = conn.cursor() # Get node count cursor.execute(''' SELECT COUNT(*) as count FROM complete_contact_tracking WHERE role IN ('repeater', 'roomserver') AND latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 ''') node_count = cursor.fetchone()['count'] # Get edge statistics cursor.execute(''' SELECT COUNT(*) as total_edges, SUM(observation_count) as total_observations, AVG(observation_count) as avg_observations, AVG(geographic_distance) as avg_distance, MIN(geographic_distance) as min_distance, MAX(geographic_distance) as max_distance, COUNT(CASE WHEN from_public_key IS NOT NULL THEN 1 END) as edges_with_from_key, COUNT(CASE WHEN to_public_key IS NOT NULL THEN 1 END) as edges_with_to_key, COUNT(CASE WHEN from_public_key IS NOT NULL AND to_public_key IS NOT NULL THEN 1 END) as edges_with_both_keys, COUNT(CASE WHEN LENGTH(from_prefix) >= 4 AND LENGTH(to_prefix) >= 4 THEN 1 END) as multibyte_edges, COUNT(CASE WHEN last_seen >= datetime("now", "-1 days") THEN 1 END) as recent_edges_24h FROM mesh_connections ''') edge_stats = cursor.fetchone() # Get most connected nodes cursor.execute(''' SELECT LOWER(prefix) AS prefix, SUM(connection_count) AS connection_count FROM ( SELECT from_prefix AS prefix, COUNT(*) AS connection_count FROM mesh_connections GROUP BY from_prefix UNION ALL SELECT to_prefix AS prefix, COUNT(*) AS connection_count FROM mesh_connections GROUP BY to_prefix ) GROUP BY LOWER(prefix) ORDER BY connection_count DESC, prefix LIMIT 10 ''') top_connected = [ (row['prefix'], row['connection_count']) for row in cursor.fetchall() ] stats = { 'node_count': node_count, 'total_edges': edge_stats['total_edges'] or 0, 'total_observations': edge_stats['total_observations'] or 0, 'avg_observations': round(edge_stats['avg_observations'] or 0, 2), 'avg_distance': round(edge_stats['avg_distance'] or 0, 2) if edge_stats['avg_distance'] else None, 'min_distance': round(edge_stats['min_distance'] or 0, 2) if edge_stats['min_distance'] else None, 'max_distance': round(edge_stats['max_distance'] or 0, 2) if edge_stats['max_distance'] else None, 'edges_with_from_key': edge_stats['edges_with_from_key'] or 0, 'edges_with_to_key': edge_stats['edges_with_to_key'] or 0, 'edges_with_both_keys': edge_stats['edges_with_both_keys'] or 0, 'multibyte_edges': edge_stats['multibyte_edges'] or 0, 'top_connected': [{'prefix': prefix, 'count': count} for prefix, count in top_connected], 'recent_edges_24h': edge_stats['recent_edges_24h'] or 0 } # Bot's own position (config [Bot] bot_latitude/bot_longitude), used by # the mesh page to frame the initial map view on the home mesh bot_lat = self.config.getfloat('Bot', 'bot_latitude', fallback=None) bot_lon = self.config.getfloat('Bot', 'bot_longitude', fallback=None) if bot_lat is not None and bot_lon is not None: stats['bot_location'] = {'latitude': bot_lat, 'longitude': bot_lon} return jsonify(stats) except Exception as e: self.logger.error(f"Error getting mesh stats: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/mesh/resolve-path', methods=['POST']) def api_resolve_path(): """Resolve a hex path to repeater names and locations using the same algorithm as path command""" try: data = request.get_json() if not data: return jsonify({'error': 'JSON body required'}), 400 path_input = data.get('path', '').strip() if not path_input: return jsonify({'error': 'Path input required'}), 400 # Check if db_manager is initialized if not hasattr(self, 'db_manager') or not self.db_manager: self.logger.error("db_manager not initialized") return jsonify({'error': 'Database not initialized'}), 500 resolved_path = self._resolve_path(path_input) return jsonify(resolved_path) except Exception as e: import traceback error_trace = traceback.format_exc() self.logger.error(f"Error resolving path: {e}\n{error_trace}") return jsonify({'error': str(e), 'traceback': error_trace}), 500 @self.app.route('/api/stream_data', methods=['POST']) @self._api_errors('Error in stream_data endpoint') def api_stream_data(): """API endpoint for receiving real-time data from bot. Requires a valid X-Stream-Token header matching the token stored in DB metadata by BotIntegration. This prevents unauthenticated callers from injecting fake stream data when the web viewer is network-accessible. """ if not current_app.config.get('TESTING'): token = request.headers.get('X-Stream-Token', '') expected = self.db_manager.get_metadata('internal.stream_token') if self.db_manager else None if not expected or not token or token != expected: return jsonify({'error': 'Unauthorized'}), 401 data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 data_type = data.get('type') if data_type == 'command': self._handle_command_data(data.get('data', {})) elif data_type == 'packet': self._handle_packet_data(data.get('data', {})) elif data_type == 'mesh_edge': self._handle_mesh_edge_data(data.get('data', {})) elif data_type == 'mesh_node': self._handle_mesh_node_data(data.get('data', {})) else: return jsonify({'error': 'Invalid data type'}), 400 return jsonify({'status': 'success'}) @self.app.route('/api/recent_commands') @self._api_errors('Error getting recent commands') def api_recent_commands(): """API endpoint to get recent commands from database""" import json import sqlite3 import time # Get commands from last 60 minutes cutoff_time = time.time() - (60 * 60) # 60 minutes ago with closing(sqlite3.connect(self.db_path, timeout=60)) as conn: cursor = conn.cursor() cursor.execute(''' SELECT data FROM packet_stream WHERE type = 'command' AND timestamp > ? ORDER BY timestamp DESC LIMIT 100 ''', (cutoff_time,)) rows = cursor.fetchall() # Parse and return commands commands = [] for (data_json,) in rows: try: command_data = json.loads(data_json) commands.append(command_data) except Exception as e: self.logger.debug(f"Error parsing command data: {e}") return jsonify({'commands': commands}) # ── Export ────────────────────────────────────────────────────────── @self.app.route('/api/export/contacts') def api_export_contacts(): """Export contact tracking data as CSV or JSON. Query params: format=csv|json (default json), since=24h|7d|30d|90d|all (default 30d).""" import csv import io fmt = request.args.get('format', 'json').lower() since = request.args.get('since', '30d') if since not in ('24h', '7d', '30d', '90d', 'all'): since = '30d' try: result = self._get_tracking_data(since=since, include_detail=True) contacts = result.get('tracking_data', []) if fmt == 'csv': fields = [ 'user_id', 'username', 'role', 'device_type', 'latitude', 'longitude', 'city', 'state', 'country', 'snr', 'hop_count', 'first_heard', 'last_seen', 'advert_count', 'total_messages', 'distance', 'is_starred', ] buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=fields, extrasaction='ignore') w.writeheader() w.writerows(contacts) return Response( buf.getvalue(), mimetype='text/csv', headers={'Content-Disposition': f'attachment; filename="contacts_{since}.csv"'}, ) else: import json as _json body = _json.dumps(contacts, indent=2, default=str) return Response( body, mimetype='application/json', headers={'Content-Disposition': f'attachment; filename="contacts_{since}.json"'}, ) except Exception as e: self.logger.error(f"Error exporting contacts: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/export/paths') def api_export_paths(): """Export observed path data as CSV or JSON. Query params: format=csv|json (default json), since=24h|7d|30d|90d|all (default 30d).""" import csv import io import json as _json import sqlite3 fmt = request.args.get('format', 'json').lower() since = request.args.get('since', '30d') if since not in ('24h', '7d', '30d', '90d', 'all'): since = '30d' try: days_map = {'24h': 1, '7d': 7, '30d': 30, '90d': 90} where = ( f" AND op.last_seen >= datetime('now', 'localtime', '-{days_map[since]} days')" if since != 'all' else '' ) with closing(sqlite3.connect(self.db_path, timeout=60)) as conn: conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(f""" SELECT op.public_key, c.name AS contact_name, op.path_hex, op.path_length, op.observation_count, op.last_seen, op.from_prefix, op.to_prefix, op.bytes_per_hop, op.packet_type FROM observed_paths op LEFT JOIN complete_contact_tracking c ON op.public_key = c.public_key WHERE op.packet_type = 'advert' AND op.public_key IS NOT NULL {where} ORDER BY op.last_seen DESC LIMIT 10000 """) rows = [dict(r) for r in cursor.fetchall()] if fmt == 'csv': fields = [ 'public_key', 'contact_name', 'path_hex', 'path_length', 'observation_count', 'last_seen', 'from_prefix', 'to_prefix', 'bytes_per_hop', 'packet_type', ] buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=fields, extrasaction='ignore') w.writeheader() w.writerows(rows) return Response( buf.getvalue(), mimetype='text/csv', headers={'Content-Disposition': f'attachment; filename="paths_{since}.csv"'}, ) else: body = _json.dumps(rows, indent=2, default=str) return Response( body, mimetype='application/json', headers={'Content-Disposition': f'attachment; filename="paths_{since}.json"'}, ) except Exception as e: self.logger.error(f"Error exporting paths: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/geocode-contact', methods=['POST']) def api_geocode_contact(): """Manually geocode a contact by public_key""" try: data = request.get_json() if not data or 'public_key' not in data: return jsonify({'error': 'public_key is required'}), 400 public_key = data['public_key'] # Get contact data from database with self._db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT latitude, longitude, name, city, state, country FROM complete_contact_tracking WHERE public_key = ? ''', (public_key,)) contact = cursor.fetchone() if not contact: return jsonify({'error': 'Contact not found'}), 404 lat = contact['latitude'] lon = contact['longitude'] name = contact['name'] # Check if we have valid coordinates if lat is None or lon is None or lat == 0.0 or lon == 0.0: return jsonify({'error': 'Contact does not have valid coordinates'}), 400 # Perform geocoding self.logger.info(f"Manual geocoding requested for {name} ({public_key[:16]}...) at coordinates {lat}, {lon}") # sqlite3.Row objects use dictionary-style access with [] current_city = contact['city'] current_state = contact['state'] current_country = contact['country'] self.logger.debug(f"Current location data - city: {current_city}, state: {current_state}, country: {current_country}") # Outside the try below: a failure to build the manager is a # setup problem, not a geocoding one, and must not be reported # to the user as "Geocoding exception". repeater_manager = self._get_repeater_manager() try: location_info = repeater_manager._get_full_location_from_coordinates(lat, lon) self.logger.debug(f"Geocoding result for {name}: {location_info}") except Exception as geocode_error: self.logger.error(f"Exception during geocoding for {name} at {lat}, {lon}: {geocode_error}", exc_info=True) return jsonify({ 'success': False, 'error': f'Geocoding exception: {str(geocode_error)}', 'location': {} }), 500 # Check if geocoding returned any useful data has_location_data = location_info.get('city') or location_info.get('state') or location_info.get('country') if not has_location_data: self.logger.warning(f"Geocoding returned no location data for {name} at {lat}, {lon}. Result: {location_info}") return jsonify({ 'success': False, 'error': 'Geocoding returned no location data. The coordinates may be invalid or the geocoding service may be unavailable.', 'location': location_info }), 500 # Update database with new location data cursor.execute(''' UPDATE complete_contact_tracking SET city = ?, state = ?, country = ? WHERE public_key = ? ''', ( location_info.get('city'), location_info.get('state'), location_info.get('country'), public_key )) conn.commit() # Build success message with what was found found_parts = [] if location_info.get('city'): found_parts.append(f"city: {location_info['city']}") if location_info.get('state'): found_parts.append(f"state: {location_info['state']}") if location_info.get('country'): found_parts.append(f"country: {location_info['country']}") success_message = f'Successfully geocoded {name} - Found {", ".join(found_parts)}' self.logger.info(f"Successfully geocoded {name}: {location_info}") return jsonify({ 'success': True, 'location': location_info, 'message': success_message }) except Exception as e: self.logger.error(f"Error geocoding contact: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/toggle-star-contact', methods=['POST']) def api_toggle_star_contact(): """Toggle star status for any contact by public_key.""" try: data = request.get_json() if not data or 'public_key' not in data: return jsonify({'error': 'public_key is required'}), 400 public_key = data['public_key'] # Get contact data from database with self._db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT name, is_starred, role FROM complete_contact_tracking WHERE public_key = ? ''', (public_key,)) contact = cursor.fetchone() if not contact: return jsonify({'error': 'Contact not found'}), 404 # Toggle star status current_starred = contact['is_starred'] new_star_status = 1 if not current_starred else 0 cursor.execute(''' UPDATE complete_contact_tracking SET is_starred = ? WHERE public_key = ? ''', (new_star_status, public_key)) conn.commit() action = 'starred' if new_star_status else 'unstarred' self.logger.info(f"Contact {contact['name']} ({public_key[:16]}...) {action}") return jsonify({ 'success': True, 'is_starred': bool(new_star_status), 'message': f'Contact {action} successfully' }) except Exception as e: self.logger.error(f"Error toggling star status: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/decode-path', methods=['POST']) def api_decode_path(): """Decode path hex string to repeater names (similar to path command). Optional bytes_per_hop (1, 2, or 3): use when path came from a packet with multi-byte hops so decoding and graph selection use the correct prefix length.""" try: data = request.get_json() if not data or 'path_hex' not in data: return jsonify({'error': 'path_hex is required'}), 400 path_hex = data['path_hex'] if not path_hex: return jsonify({'error': 'path_hex cannot be empty'}), 400 bytes_per_hop = data.get('bytes_per_hop') if bytes_per_hop is not None: try: bytes_per_hop = int(bytes_per_hop) if bytes_per_hop not in (1, 2, 3): bytes_per_hop = None except (TypeError, ValueError): bytes_per_hop = None # Decode the path (use bytes_per_hop when provided, e.g. from packet/contact) decoded_path = self._decode_path_hex(path_hex, bytes_per_hop=bytes_per_hop) return jsonify({ 'success': True, 'path': decoded_path }) except Exception as e: self.logger.error(f"Error decoding path: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/delete-contact', methods=['POST']) def api_delete_contact(): """Delete a contact from the complete contact tracking database""" try: data = request.get_json() if not data or 'public_key' not in data: return jsonify({'error': 'public_key is required'}), 400 public_key = data['public_key'] # Get contact data from database to log what we're deleting with self._db_connection() as conn: cursor = conn.cursor() # Check if contact exists cursor.execute(''' SELECT name, role, device_type FROM complete_contact_tracking WHERE public_key = ? ''', (public_key,)) contact = cursor.fetchone() if not contact: return jsonify({'error': 'Contact not found'}), 404 contact_name = contact['name'] contact_role = contact['role'] contact_device_type = contact['device_type'] # Delete from all related tables deleted_counts = {} # Delete from complete_contact_tracking cursor.execute('DELETE FROM complete_contact_tracking WHERE public_key = ?', (public_key,)) deleted_counts['complete_contact_tracking'] = cursor.rowcount # Delete from daily_stats cursor.execute('DELETE FROM daily_stats WHERE public_key = ?', (public_key,)) deleted_counts['daily_stats'] = cursor.rowcount # Delete from repeater_contacts if it exists try: cursor.execute('DELETE FROM repeater_contacts WHERE public_key = ?', (public_key,)) deleted_counts['repeater_contacts'] = cursor.rowcount except sqlite3.OperationalError: # Table might not exist, that's okay deleted_counts['repeater_contacts'] = 0 conn.commit() # Log the deletion self.logger.info(f"Contact deleted: {contact_name} ({public_key[:16]}...) - Role: {contact_role}, Device: {contact_device_type}") self.logger.debug(f"Deleted counts: {deleted_counts}") return jsonify({ 'success': True, 'message': f'Contact "{contact_name}" has been deleted successfully', 'deleted_counts': deleted_counts }) except Exception as e: self.logger.error(f"Error deleting contact: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/contacts/purge-preview') def api_contacts_purge_preview(): """Return count and sample of contacts not heard within the last N days.""" days = request.args.get('days', 30, type=int) if days < 1: return jsonify({'error': 'days must be >= 1'}), 400 try: with self._db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT COUNT(*) AS cnt FROM complete_contact_tracking WHERE last_heard < datetime('now', 'localtime', ? || ' days') ''', (f'-{days}',)) count = cursor.fetchone()['cnt'] cursor.execute(''' SELECT name, role, last_heard FROM complete_contact_tracking WHERE last_heard < datetime('now', 'localtime', ? || ' days') ORDER BY last_heard ASC LIMIT 5 ''', (f'-{days}',)) samples = [dict(r) for r in cursor.fetchall()] return jsonify({'count': count, 'days': days, 'samples': samples}) except Exception as e: self.logger.error(f"Error in purge preview: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/contacts/purge', methods=['POST']) def api_contacts_purge(): """Delete all contacts not heard within the last N days.""" data = request.get_json(silent=True) or {} days = data.get('days', 30) try: days = int(days) except (TypeError, ValueError): return jsonify({'error': 'days must be an integer'}), 400 if days < 1: return jsonify({'error': 'days must be >= 1'}), 400 try: with self._db_connection() as conn: cursor = conn.cursor() cutoff = f'-{days} days' # Collect public_keys to purge so we can cascade cursor.execute(''' SELECT public_key FROM complete_contact_tracking WHERE last_heard < datetime('now', 'localtime', ?) ''', (cutoff,)) keys = [r['public_key'] for r in cursor.fetchall()] if not keys: return jsonify({'success': True, 'deleted': 0, 'message': 'No contacts matched the threshold'}) placeholders = ','.join('?' * len(keys)) cursor.execute(f'DELETE FROM complete_contact_tracking WHERE public_key IN ({placeholders})', keys) deleted = cursor.rowcount cursor.execute(f'DELETE FROM daily_stats WHERE public_key IN ({placeholders})', keys) try: cursor.execute(f'DELETE FROM repeater_contacts WHERE public_key IN ({placeholders})', keys) except sqlite3.OperationalError: pass conn.commit() self.logger.info(f"Purged {deleted} contact(s) not heard in {days}+ days") return jsonify({'success': True, 'deleted': deleted, 'message': f'Purged {deleted} contact(s) not heard in {days}+ days'}) except Exception as e: self.logger.error(f"Error purging contacts: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/greeter') def api_greeter(): """Get greeter data including rollout status, settings, and greeted users""" try: with self._db_connection() as conn: cursor = conn.cursor() # Check if greeter tables exist cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='greeter_rollout'") if not cursor.fetchone(): return jsonify({ 'enabled': False, 'rollout_active': False, 'settings': {}, 'greeted_users': [], 'error': 'Greeter tables not found' }) # Get active rollout status cursor.execute(''' SELECT id, rollout_started_at, rollout_days, rollout_completed, datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date, datetime('now') as current_time FROM greeter_rollout WHERE rollout_completed = 0 ORDER BY rollout_started_at DESC LIMIT 1 ''') rollout = cursor.fetchone() rollout_active = False rollout_data = None time_remaining = None if rollout: rollout_id = rollout['id'] started_at_str = rollout['rollout_started_at'] rollout_days = rollout['rollout_days'] end_date_str = rollout['end_date'] current_time_str = rollout['current_time'] end_date = datetime.fromisoformat(end_date_str) current_time = datetime.fromisoformat(current_time_str) if current_time < end_date: rollout_active = True remaining_seconds = (end_date - current_time).total_seconds() time_remaining = { 'days': int(remaining_seconds // 86400), 'hours': int((remaining_seconds % 86400) // 3600), 'minutes': int((remaining_seconds % 3600) // 60), 'seconds': int(remaining_seconds % 60), 'total_seconds': int(remaining_seconds) } rollout_data = { 'id': rollout_id, 'started_at': started_at_str, 'days': rollout_days, 'end_date': end_date_str } # Get greeter settings from config settings = { 'enabled': self.config.getboolean('Greeter_Command', 'enabled', fallback=False), 'greeting_message': self.config.get('Greeter_Command', 'greeting_message', fallback='Welcome to the mesh, {sender}!'), 'rollout_days': self.config.getint('Greeter_Command', 'rollout_days', fallback=7), 'include_mesh_info': self.config.getboolean('Greeter_Command', 'include_mesh_info', fallback=True), 'mesh_info_format': self.config.get('Greeter_Command', 'mesh_info_format', fallback='\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters'), 'per_channel_greetings': self.config.getboolean('Greeter_Command', 'per_channel_greetings', fallback=False) } # Generate sample greeting — use str.replace() instead of .format() # to avoid KeyError / info leaks from user-controlled templates sample_greeting = settings['greeting_message'].replace('{sender}', 'SampleUser') if settings['include_mesh_info']: sample_mesh_info = ( settings['mesh_info_format'] .replace('{total_contacts}', '100') .replace('{repeaters}', '5') .replace('{companions}', '95') .replace('{recent_activity_24h}', '10') ) sample_greeting += sample_mesh_info # Check if message_stats table exists for last seen data cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='message_stats'") has_message_stats = cursor.fetchone() is not None # Get greeted users - use GROUP BY to ensure only one entry per (sender_id, channel) # This handles any potential duplicates that might exist in the database # We use MIN(greeted_at) to get the earliest (first) greeting time # If per_channel_greetings is False, we'll still show one entry per user (channel will be NULL) # If per_channel_greetings is True, we'll show one entry per user per channel cursor.execute(''' SELECT sender_id, channel, MIN(greeted_at) as greeted_at, MAX(rollout_marked) as rollout_marked FROM greeted_users GROUP BY sender_id, channel ORDER BY MIN(greeted_at) DESC LIMIT 500 ''') greeted_users_rows = cursor.fetchall() greeted_users = [] for row in greeted_users_rows: # Access row data - handle both dict-style (Row) and tuple access try: sender_id = row['sender_id'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[0] channel_raw = row['channel'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[1] greeted_at = row['greeted_at'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[2] rollout_marked = row['rollout_marked'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[3] except (KeyError, IndexError, TypeError) as e: self.logger.error(f"Error accessing row data: {e}, row type: {type(row)}") continue sender_id = str(sender_id) if sender_id else '' channel = str(channel_raw) if channel_raw else '(global)' # Get last seen timestamp from message_stats if available last_seen = None if has_message_stats: # Get the most recent channel message (not DM) for this user # If per_channel_greetings is enabled, match the specific channel # Otherwise, get the most recent message from any channel if channel_raw: # Use the raw channel value, not the formatted one cursor.execute(''' SELECT MAX(timestamp) as last_seen FROM message_stats WHERE sender_id = ? AND channel = ? AND is_dm = 0 AND channel IS NOT NULL ''', (sender_id, channel_raw)) else: # Global greeting - get last seen from any channel cursor.execute(''' SELECT MAX(timestamp) as last_seen FROM message_stats WHERE sender_id = ? AND is_dm = 0 AND channel IS NOT NULL ''', (sender_id,)) result = cursor.fetchone() if result and result['last_seen']: last_seen = result['last_seen'] greeted_users.append({ 'sender_id': sender_id, 'channel': channel, 'greeted_at': str(greeted_at), 'rollout_marked': bool(rollout_marked), 'last_seen': last_seen }) return jsonify({ 'enabled': settings['enabled'], 'rollout_active': rollout_active, 'rollout_data': rollout_data, 'time_remaining': time_remaining, 'settings': settings, 'sample_greeting': sample_greeting, 'greeted_users': greeted_users, 'total_greeted': len(greeted_users) }) except Exception as e: self.logger.error(f"Error getting greeter data: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @self.app.route('/api/greeter/end-rollout', methods=['POST']) def api_end_rollout(): """End the active onboarding period""" try: with self._db_connection() as conn: cursor = conn.cursor() # Find active rollout cursor.execute(''' SELECT id FROM greeter_rollout WHERE rollout_completed = 0 ORDER BY rollout_started_at DESC LIMIT 1 ''') rollout = cursor.fetchone() if not rollout: return jsonify({'success': False, 'error': 'No active rollout found'}), 404 rollout_id = rollout['id'] # Mark rollout as completed cursor.execute(''' UPDATE greeter_rollout SET rollout_completed = 1 WHERE id = ? ''', (rollout_id,)) conn.commit() self.logger.info(f"Greeter rollout {rollout_id} ended manually via web viewer") return jsonify({ 'success': True, 'message': 'Onboarding period ended successfully' }) except Exception as e: self.logger.error(f"Error ending rollout: {e}", exc_info=True) return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/greeter/ungreet', methods=['POST']) def api_ungreet_user(): """Mark a user as ungreeted (remove from greeted_users table)""" try: data = request.get_json() if not data or 'sender_id' not in data: return jsonify({'error': 'sender_id is required'}), 400 sender_id = data['sender_id'] channel = data.get('channel') # Optional - if None, removes global greeting with self._db_connection() as conn: cursor = conn.cursor() # Check if user exists if channel and channel != '(global)': cursor.execute(''' SELECT id FROM greeted_users WHERE sender_id = ? AND channel = ? ''', (sender_id, channel)) else: cursor.execute(''' SELECT id FROM greeted_users WHERE sender_id = ? AND channel IS NULL ''', (sender_id,)) if not cursor.fetchone(): return jsonify({'error': 'User not found in greeted users'}), 404 # Delete the record if channel and channel != '(global)': cursor.execute(''' DELETE FROM greeted_users WHERE sender_id = ? AND channel = ? ''', (sender_id, channel)) else: cursor.execute(''' DELETE FROM greeted_users WHERE sender_id = ? AND channel IS NULL ''', (sender_id,)) conn.commit() self.logger.info(f"User {sender_id} marked as ungreeted (channel: {channel or 'global'})") return jsonify({ 'success': True, 'message': f'User {sender_id} marked as ungreeted' }) except Exception as e: self.logger.error(f"Error ungreeting user: {e}", exc_info=True) return jsonify({'success': False, 'error': str(e)}), 500 # ── Region warnings (regional flood scope) ─────────────────────────── def _region_warning_channel_limit() -> int: """Channel body budget for a region warning sent on a channel. The device's own name is authoritative for the command layer, but the viewer is a separate process with no radio, so it falls back to the configured one. They match on any install where the bot manages the device name. The warning's scope can differ per channel (``flood_scope.``), and the page shows one number, so any regional scope that could apply costs the regional overhead. Under-promising here only means the operator writes a message that fits everywhere. """ name = (self.config.get('Bot', 'bot_name', fallback='Bot') or 'Bot').strip() limit = channel_body_limit(name or 'Bot') candidates = [] if self.config.has_section(region_warning.CONFIG_SECTION): candidates.append( self.config.get(region_warning.CONFIG_SECTION, 'flood_scope', fallback='', raw=True) ) if self.config.has_section('Channels'): explicit = (candidates[0] if candidates else '').strip() if not explicit: for key, value in self.config.items('Channels', raw=True): if key == 'outgoing_flood_scope_override' or key.startswith('flood_scope.'): candidates.append(value) if any(not flood_scope.is_global_marker((c or '').strip()) for c in candidates): limit -= CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD return limit @self.app.route('/api/region-warnings') def api_region_warnings(): """Settings, traffic tallies, budget and recent decisions for the page.""" try: # Re-read from disk so the page reflects edits made elsewhere. self.config = self._load_merged_config() settings = region_warning.load_settings(self.config) try: days = max(1, min(int(request.args.get('days', 14)), 90)) except (TypeError, ValueError): days = 14 known_channels = [] try: known_channels = [ c.get('name') for c in self._get_channels() if c.get('name') ] except Exception: pass return jsonify({ 'settings': region_warning.settings_to_config_values(settings), 'defaults': region_warning.settings_to_config_values( region_warning.RegionWarningSettings() ), 'default_message': region_warning.DEFAULT_MESSAGE, 'traffic': region_warning.traffic_summary( self.db_manager, self.config, days, self.logger ), 'series': region_warning.daily_series( self.db_manager, self.config, days, self.logger ), 'budget': region_warning.warning_budget( self.db_manager, settings, self.config, self.logger ), 'events': region_warning.recent_events(self.db_manager, 50), 'limits': { 'dm': region_warning.DM_BODY_LIMIT, 'channel': _region_warning_channel_limit(), }, 'known_channels': known_channels, }) except Exception: self.logger.exception("Error building region warning view") return jsonify({'error': 'Internal error — see server logs'}), 500 @self.app.route('/api/region-warnings/settings', methods=['POST']) def api_region_warnings_save(): """Persist [Region_Warnings] and queue a hot config reload.""" data = request.get_json(silent=True) or {} def _as_bool(key, default): raw = data.get(key, default) if isinstance(raw, bool): return raw return str(raw).strip().lower() in ('1', 'true', 'yes', 'on') def _as_number(key, default, minimum=0.0, maximum=None): raw = data.get(key, default) try: value = float(raw) except (TypeError, ValueError): raise ValueError(f'{key} must be a number') if value < minimum: raise ValueError(f'{key} must be at least {minimum:g}') if maximum is not None and value > maximum: raise ValueError(f'{key} must be at most {maximum:g}') return value try: delivery = str(data.get('delivery', 'dm')).strip().lower() if delivery not in (region_warning.DELIVERY_DM, region_warning.DELIVERY_CHANNEL): raise ValueError('delivery must be "dm" or "channel"') message = str(data.get('message') or '').strip() or region_warning.DEFAULT_MESSAGE if '\n' in message or '\r' in message: raise ValueError('message must be a single line') if '%' in message: # config.ini is read with configparser's interpolation on, so a # bare % raises on every later read of the section — including # the bot's own config validation, which would then reject every # hot reload until someone hand-edited the file. raise ValueError('message cannot contain "%"; write "percent" instead') if len(message) > 500: # Far above the 158-byte send budget, but this lands in # config.ini and in every timestamped backup of it. raise ValueError('message must be 500 characters or fewer') channels = data.get('channels') if isinstance(channels, list): channel_parts = channels else: channel_parts = str(channels or '').split(',') normalized_channels = [] for part in channel_parts: name = region_warning.normalize_channel(part) if name and name not in normalized_channels: normalized_channels.append(name) settings = region_warning.RegionWarningSettings( enabled=_as_bool('enabled', False), dry_run=_as_bool('dry_run', True), delivery=delivery, channels=tuple(normalized_channels), message=message, min_unscoped_messages=int(_as_number('min_unscoped_messages', 3, 1, 100)), per_sender_cooldown_hours=_as_number('per_sender_cooldown_hours', 168, 0, 8760), mesh_cooldown_minutes=_as_number('mesh_cooldown_minutes', 30, 0, 10080), max_warnings_per_day=int(_as_number('max_warnings_per_day', 6, 0, 1000)), track_traffic=_as_bool('track_traffic', True), ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 section = region_warning.CONFIG_SECTION target_path = ( self.local_config_path if section in self._local_sections else self.config_path ) try: store = get_settings_store(self.config, target_path, self.db_manager) result = store.write_values( section, region_warning.settings_to_config_values(settings) ) except IniValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 except Exception: self.logger.exception("Error saving region warning settings") return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 backup_path = result.get('backup_path', '') if isinstance(result, dict) else '' reload_queued = _queue_config_reload() self.logger.info( "Region warning settings saved (enabled=%s, dry_run=%s, delivery=%s)", settings.enabled, settings.dry_run, settings.delivery, ) return jsonify({ 'success': True, 'backup_path': backup_path, 'reload_queued': reload_queued, 'settings': region_warning.settings_to_config_values(settings), }) # ── Region scopes ([Channels] flood_scopes) ────────────────────────── def _region_scope_target_path(): """Where a [Channels] write lands: the local overlay wins if it has the section, because that is the copy the merged config reads last.""" return ( self.local_config_path if 'Channels' in self._local_sections else self.config_path ) def _region_scope_view(): """Effective region-scope settings, read the way the bot reads them.""" # [Channels] is canonical; [Bot] is still honoured with a warning by # CommandManager, so read it the same way or the page would show # "replies to every scope" while the bot enforces an allowlist. raw = '' legacy_section = None for section in ('Channels', 'Bot'): if self.config.has_section(section) and self.config.has_option( section, 'flood_scopes' ): candidate = (self.config.get(section, 'flood_scopes') or '').strip() if not candidate: continue raw = candidate if section != 'Channels': legacy_section = section break scopes, allow_global = flood_scope.split_allowlist(raw) override_raw = '' if self.config.has_section('Channels') and self.config.has_option( 'Channels', 'outgoing_flood_scope_override' ): override_raw = ( self.config.get('Channels', 'outgoing_flood_scope_override') or '' ).strip() override = ( '' if flood_scope.is_global_marker(override_raw) else flood_scope.normalize_scope_name(override_raw) ) # Read-only, but it is the answer to "why does that channel ignore # the default?", so the page shows it rather than making the # operator open config.ini to find out. channel_overrides = [] if self.config.has_section('Channels'): for key, value in self.config.items('Channels'): if not key.startswith('flood_scope.') or len(key) <= len('flood_scope.'): continue configured = (value or '').strip() channel_overrides.append({ 'channel': key[len('flood_scope.'):], 'scope': ( '' if flood_scope.is_global_marker(configured) else flood_scope.normalize_scope_name(configured) ), }) channel_overrides.sort(key=lambda entry: entry['channel'].lower()) target = _region_scope_target_path() if target == self.local_config_path: target_label = os.path.join( os.path.basename(os.path.dirname(target)), os.path.basename(target) ) else: target_label = os.path.basename(target) return { 'allowlist_active': bool(scopes or allow_global), 'scopes': scopes, 'allow_global': allow_global, 'outgoing_override': override, 'channel_overrides': channel_overrides, 'legacy_section': legacy_section, 'target': target_label, 'max_name_length': flood_scope.MAX_SCOPE_NAME_LENGTH, } @self.app.route('/api/region-scopes') def api_region_scopes_get(): """Regional flood scopes from [Channels], as the bot resolves them.""" try: # Re-read from disk so the page reflects edits made elsewhere, # and so the local-overlay target is resolved against what is # on disk now rather than at viewer startup. self.config = self._load_merged_config() return jsonify(_region_scope_view()) except Exception: self.logger.exception("Error reading region scopes") return jsonify({'error': 'Internal error — see server logs'}), 500 @self.app.route('/api/region-scopes', methods=['POST']) def api_region_scopes_save(): """Persist [Channels] flood_scopes / outgoing_flood_scope_override.""" data = request.get_json(silent=True) or {} def _as_bool(key, default=False): raw = data.get(key, default) if isinstance(raw, bool): return raw return str(raw).strip().lower() in ('1', 'true', 'yes', 'on') try: submitted = data.get('scopes') if isinstance(submitted, (list, tuple)): entries = [str(part).strip() for part in submitted if str(part).strip()] else: entries = flood_scope.parse_scope_list(submitted) allow_global = _as_bool('allow_global', False) scopes: list[str] = [] for entry in entries: canonical = flood_scope.validate_scope_name(entry) # '*' typed into the list box means the same thing as the # checkbox; fold it in rather than writing it twice. if flood_scope.is_global_marker(canonical): allow_global = True elif canonical not in scopes: scopes.append(canonical) allowlist_enabled = _as_bool('allowlist_enabled', bool(scopes or allow_global)) if allowlist_enabled and not scopes and not allow_global: raise ValueError( 'Add at least one region scope, or turn the allowlist off ' 'so the bot replies whatever the scope' ) # '*' last, matching the order config.ini.example documents. flood_scopes_value = ( flood_scope.format_scope_list(scopes + (['*'] if allow_global else [])) if allowlist_enabled else '' ) override_raw = str(data.get('outgoing_override') or '').strip() # Every global marker means the same send path, but only the # empty value keeps send_channel_message from logging "override # was not applied" on each global send. Store the quiet one. override_value = ( '' if flood_scope.is_global_marker(override_raw) else flood_scope.validate_scope_name(override_raw) ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 try: # Resolve the write target against the config on disk now: the # local overlay may have grown a [Channels] section since the # viewer started, and it would silently win over a base write. self.config = self._load_merged_config() store = get_settings_store( self.config, _region_scope_target_path(), self.db_manager ) result = store.write_values('Channels', { 'flood_scopes': flood_scopes_value, 'outgoing_flood_scope_override': override_value, }) except IniValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 except OSError: self.logger.exception("Error writing region scopes") return jsonify({ 'success': False, 'error': 'Could not write config.ini — check file permissions', }), 500 except Exception: self.logger.exception("Error saving region scopes") return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 backup_path = result.get('backup_path', '') if isinstance(result, dict) else '' reload_op_id = _queue_config_reload_id() self.logger.info( "Region scopes saved: flood_scopes=%r outgoing_flood_scope_override=%r", flood_scopes_value, override_value, ) return jsonify({ 'success': True, 'backup_path': backup_path, 'reload_queued': reload_op_id is not None, 'reload_operation_id': reload_op_id, 'settings': _region_scope_view(), }) # Feed management API endpoints def _schedule_tz(): from modules.utils import get_config_timezone tz, _name = get_config_timezone(self.config, self.logger) return tz def _queue_config_reload_id(): """Queue a config reload and return its operation id, or None. The id lets a caller poll /api/channel-operations/ and report what the bot actually did with the edit, instead of claiming success because a row was inserted. """ try: return self._queue_operation('config_reload') except Exception: self.logger.exception("Failed to queue config reload") return None def _queue_config_reload(): """Ask the bot to re-read config.ini; it re-registers scheduled jobs.""" return _queue_config_reload_id() is not None # The duplicate check and the write have to be one critical section, or two # concurrent creates for the same schedule both pass the check and the second # silently replaces the first instead of getting the promised 409. schedule_write_lock = threading.Lock() def _existing_schedules(): return {e['schedule'] for e in read_entries(self.config_path, _schedule_tz())} @self.app.route('/api/scheduled-messages') @self._api_errors('Error reading scheduled messages') def api_scheduled_messages(): """List scheduled messages with their next run times.""" return jsonify({'entries': read_entries(self.config_path, _schedule_tz())}) @self.app.route('/api/scheduled-messages/preview', methods=['POST']) @self._api_errors('Error previewing schedule') def api_scheduled_messages_preview(): """Validate a schedule and return its next run times (powers the builder).""" data = request.get_json(silent=True) or {} try: count = int(data.get('count', 5)) except (TypeError, ValueError): return jsonify({'error': 'count must be an integer'}), 400 if not 1 <= count <= 20: return jsonify({'error': 'count must be between 1 and 20'}), 400 return jsonify(describe_schedule( data.get('schedule', ''), _schedule_tz(), message=data.get('message', ''), count=count, )) def _save_scheduled_message(data, *, replacing=None): """Shared create/update: validate, write config.ini, queue a reload.""" with schedule_write_lock: return _save_scheduled_message_locked(data, replacing=replacing) def _save_scheduled_message_locked(data, *, replacing=None): schedule = (data.get('schedule') or '').strip() channel = (data.get('channel') or '').strip() message = (data.get('message') or '').strip() scope = (data.get('scope') or '').strip() or None field_error = validate_entry(channel, message, scope) if field_error: return jsonify({'success': False, 'error': field_error}), 400 described = describe_schedule(schedule, _schedule_tz(), message=message) if not described.get('valid'): return jsonify({'success': False, 'error': described.get('error')}), 400 existing = _existing_schedules() # Checked here rather than in the route so it shares this snapshot and the # surrounding lock. if replacing is not None and replacing not in existing: return jsonify({ 'success': False, 'error': f"No scheduled message for '{replacing}'", }), 404 # Schedules are INI keys, so two entries cannot share one. Renaming onto # another entry's key would silently overwrite it. if schedule in existing and schedule != replacing: return jsonify({ 'success': False, 'error': ( f"A scheduled message already exists for '{schedule}'. " "Edit that one, or use a different schedule." ), }), 409 updates = {SCHEDULED_MESSAGES_SECTION: {schedule: compose_value(channel, message, scope)}} deletes = None if replacing and replacing != schedule: deletes = {SCHEDULED_MESSAGES_SECTION: [replacing]} try: summary = update_ini_values(self.config_path, updates, deletes) except IniValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 except OSError as exc: self.logger.error("Failed to write scheduled message: %s", exc) return jsonify({ 'success': False, 'error': 'Could not write config.ini — check file permissions', }), 500 reloaded = _queue_config_reload() if not reloaded: # Written to disk but the running bot still has the old jobs. Saying # "saved" alone would imply it is live. self.logger.warning( "Scheduled message written but the config reload could not be queued" ) self.logger.info( "Scheduled message saved: %r -> %s:%s (backup=%s)", schedule, channel, message[:40], os.path.basename(summary.get('backup_path') or '') or 'none', ) return jsonify({ 'success': True, 'reload_queued': reloaded, 'message': ( 'Saved. The bot reloads its schedule within a few seconds.' if reloaded else 'Saved to config.ini, but the bot could not be told to reload. ' 'It will pick the change up on next restart.' ), 'entry': {'schedule': schedule, 'channel': channel, 'scope': scope, 'message': message, **described}, }) @self.app.route('/api/scheduled-messages', methods=['POST']) def api_create_scheduled_message(): """Create a scheduled message.""" try: return _save_scheduled_message(request.get_json(silent=True) or {}) except Exception as e: self.logger.error(f"Error creating scheduled message: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/scheduled-messages', methods=['PUT']) def api_update_scheduled_message(): """Update a scheduled message, including changing its schedule.""" try: data = request.get_json(silent=True) or {} original = (data.get('original_schedule') or '').strip() if not original: return jsonify({'success': False, 'error': 'original_schedule is required'}), 400 # Existence is verified inside the lock, against the same snapshot the # duplicate check uses: a concurrent delete between an outside check and # the write would otherwise resurrect the entry as a new one. return _save_scheduled_message(data, replacing=original) except Exception as e: self.logger.error(f"Error updating scheduled message: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/scheduled-messages', methods=['DELETE']) def api_delete_scheduled_message(): """Delete a scheduled message.""" try: data = request.get_json(silent=True) or {} schedule = (data.get('schedule') or '').strip() if not schedule: return jsonify({'success': False, 'error': 'schedule is required'}), 400 with schedule_write_lock: if schedule not in _existing_schedules(): return jsonify({ 'success': False, 'error': f"No scheduled message for '{schedule}'", }), 404 try: update_ini_values( self.config_path, {}, {SCHEDULED_MESSAGES_SECTION: [schedule]} ) except OSError as exc: self.logger.error("Failed to delete scheduled message: %s", exc) return jsonify({ 'success': False, 'error': 'Could not write config.ini — check file permissions', }), 500 reloaded = _queue_config_reload() self.logger.info("Scheduled message deleted: %r", schedule) return jsonify({ 'success': True, 'reload_queued': reloaded, 'message': ( 'Deleted.' if reloaded else 'Deleted from config.ini, but the bot could not be told to ' 'reload. It will stop sending after the next restart.' ), }) except Exception as e: self.logger.error(f"Error deleting scheduled message: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @self.app.route('/api/feeds') @self._api_errors('Error getting feeds') def api_feeds(): """Get all feed subscriptions with statistics""" feeds = self._get_feed_subscriptions() return jsonify(feeds) @self.app.route('/api/feeds/') @self._api_errors('Error getting feed detail') def api_feed_detail(feed_id): """Get detailed information about a specific feed""" feed = self._get_feed_subscription(feed_id) if not feed: return jsonify({'error': 'Feed not found'}), 404 # Get activity and errors activity = self._get_feed_activity(feed_id) errors = self._get_feed_errors(feed_id) feed['activity'] = activity feed['errors'] = errors return jsonify(feed) @self.app.route('/api/feeds', methods=['POST']) @self._api_errors('Error creating feed') def api_create_feed(): """Create a new feed subscription""" data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 feed_id = self._create_feed_subscription(data) return jsonify({'success': True, 'id': feed_id}) @self.app.route('/api/feeds/', methods=['PUT']) @self._api_errors('Error updating feed') def api_update_feed(feed_id): """Update an existing feed subscription""" data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 success = self._update_feed_subscription(feed_id, data) if not success: return jsonify({'error': 'Feed not found'}), 404 return jsonify({'success': True}) @self.app.route('/api/feeds/', methods=['DELETE']) @self._api_errors('Error deleting feed') def api_delete_feed(feed_id): """Delete a feed subscription""" success = self._delete_feed_subscription(feed_id) if not success: return jsonify({'error': 'Feed not found'}), 404 return jsonify({'success': True}) @self.app.route('/api/feeds/default-format', methods=['GET']) def api_get_default_format(): """Get the default output format from config""" try: default_format = self.config.get('Feed_Manager', 'default_output_format', fallback='{emoji} {body|truncate:100} - {date}\n{link|truncate:50}') return jsonify({'default_format': default_format}) except Exception as e: self.logger.error(f"Error getting default format: {e}") return jsonify({'default_format': '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}'}) @self.app.route('/api/feeds/preview', methods=['POST']) @self._api_errors('Error previewing feed') def api_preview_feed(): """Preview feed items with custom output format""" data = request.get_json() if not data or 'feed_url' not in data: return jsonify({'error': 'feed_url is required'}), 400 feed_url = data['feed_url'] feed_type = data.get('feed_type', 'rss') output_format = data.get('output_format', '') api_config = data.get('api_config', {}) filter_config = data.get('filter_config') sort_config = data.get('sort_config') # Get default format from config if not provided if not output_format: output_format = self.config.get('Feed_Manager', 'default_output_format', fallback='{emoji} {body|truncate:100} - {date}\n{link|truncate:50}') # Fetch and format feed items try: preview_items = self._preview_feed_items(feed_url, feed_type, output_format, api_config, filter_config, sort_config) except ValueError as e: # SSRF validation error - return 400 return jsonify({'error': str(e)}), 400 return jsonify({ 'success': True, 'items': preview_items }) @self.app.route('/api/feeds/test', methods=['POST']) @self._api_errors('Error testing feed') def api_test_feed(): """Test a feed URL and return preview of recent items""" data = request.get_json() if not data or 'url' not in data: return jsonify({'error': 'URL is required'}), 400 url = data['url'] # Validate URL for SSRF protection if self.config.has_section('Feed_Command'): try: feed_command_allow_private = self.config.getboolean( 'Feed_Command', 'allow_private_urls', fallback=False ) except ValueError: feed_command_allow_private = False else: feed_command_allow_private = False allow_private_feeds = ( self.config.getboolean( 'Feed_Manager', 'allow_private_urls', fallback=feed_command_allow_private, ) if self.config.has_section('Feed_Manager') else feed_command_allow_private ) if not validate_external_url(url, allow_private=allow_private_feeds): return jsonify({'error': 'Invalid or unsafe URL'}), 400 return jsonify({'success': True, 'message': 'URL validated'}) @self.app.route('/api/feeds/stats') @self._api_errors('Error getting feed stats') def api_feed_stats(): """Get aggregate feed statistics""" stats = self._get_feed_statistics() return jsonify(stats) @self.app.route('/api/feeds//activity') @self._api_errors('Error getting feed activity') def api_feed_activity(feed_id): """Get activity log for a specific feed""" activity = self._get_feed_activity(feed_id, limit=50) return jsonify({'activity': activity}) @self.app.route('/api/feeds//errors') @self._api_errors('Error getting feed errors') def api_feed_errors(feed_id): """Get error history for a specific feed""" errors = self._get_feed_errors(feed_id, limit=20) return jsonify({'errors': errors}) @self.app.route('/api/feeds/errors/reset', methods=['POST']) @self._api_errors('Error resetting feed errors') def api_reset_all_feed_errors(): """Clear recorded errors for every feed""" deleted = self._reset_feed_errors() return jsonify({'success': True, 'deleted': deleted}) @self.app.route('/api/feeds//errors/reset', methods=['POST']) @self._api_errors('Error resetting feed errors') def api_reset_feed_errors(feed_id): """Clear recorded errors for a single feed""" deleted = self._reset_feed_errors(feed_id) return jsonify({'success': True, 'deleted': deleted}) @self.app.route('/api/feeds//refresh', methods=['POST']) @self._api_errors('Error refreshing feed') def api_refresh_feed(feed_id): """Manually trigger a feed check""" # This would trigger feed_manager to poll this feed immediately # For now, just acknowledge the request return jsonify({'success': True, 'message': 'Feed refresh queued'}) # Channel management API endpoints @self.app.route('/api/channels') @self._api_errors('Error getting channels') def api_channels(): """Get all configured channels""" channels = self._get_channels() return jsonify({'channels': channels}) @self.app.route('/api/channels', methods=['POST']) @self._api_errors('Error creating channel') def api_create_channel(): """Create a new channel (hashtag or custom)""" data = request.get_json() if not data or 'name' not in data: return jsonify({'error': 'Channel name is required'}), 400 channel_name = data.get('name', '').strip() channel_idx = data.get('channel_idx') channel_key = data.get('channel_key', '').strip() if not channel_name: return jsonify({'error': 'Channel name cannot be empty'}), 400 # If channel_idx not provided, find the lowest available index if channel_idx is None: channel_idx = self._get_lowest_available_channel_index() if channel_idx is None: max_channels = self.config.getint('Bot', 'max_channels', fallback=40) return jsonify({'error': f'No available channel slots. All {max_channels} channels are in use.'}), 400 # Determine if it's a hashtag channel is_hashtag = channel_name.startswith('#') # Validate custom channel has key if not is_hashtag and not channel_key: return jsonify({'error': 'Channel key is required for custom channels (channels without # prefix)'}), 400 # Validate key format if provided if channel_key: if len(channel_key) != 32: return jsonify({'error': 'Channel key must be exactly 32 hexadecimal characters'}), 400 if not all(c in '0123456789abcdefABCDEF' for c in channel_key): return jsonify({'error': 'Channel key must contain only hexadecimal characters (0-9, a-f, A-F)'}), 400 # Try to create channel via bot's channel manager result = self._add_channel_for_web(channel_idx, channel_name, channel_key if not is_hashtag else None) if result.get('success'): if result.get('pending'): # Operation is queued, return operation_id for polling return jsonify({ 'success': True, 'pending': True, 'operation_id': result.get('operation_id'), 'message': result.get('message', 'Channel operation queued') }) else: return jsonify({'success': True, 'message': 'Channel created successfully'}) else: return jsonify({'error': result.get('error', 'Failed to create channel')}), 500 @self.app.route('/api/channels/', methods=['DELETE']) @self._api_errors('Error deleting channel') def api_delete_channel(channel_idx): """Remove a channel""" result = self._remove_channel_for_web(channel_idx) if result.get('success'): if result.get('pending'): # Operation is queued, return operation_id for polling return jsonify({ 'success': True, 'pending': True, 'operation_id': result.get('operation_id'), 'message': result.get('message', 'Channel operation queued') }) else: return jsonify({'success': True, 'message': 'Channel deleted successfully'}) else: return jsonify({'error': result.get('error', 'Failed to delete channel')}), 500 @self.app.route('/api/channel-operations/', methods=['GET']) def api_get_operation_status(operation_id): """Get status of a channel operation""" try: with self._db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT status, error_message, result_data, processed_at, claimed_at FROM channel_operations WHERE id = ? ''', (operation_id,)) result = cursor.fetchone() if not result: return jsonify({'error': 'Operation not found'}), 404 status, error_msg, result_data, processed_at, claimed_at = result return jsonify({ 'operation_id': operation_id, 'status': status, 'error_message': error_msg, 'claimed_at': claimed_at, 'processed_at': processed_at, 'result_data': json.loads(result_data) if result_data else None }) except Exception as e: self.logger.error(f"Error getting operation status: {e}") return jsonify({'error': str(e)}), 500 @self.app.route('/api/channels/validate', methods=['POST']) @self._api_errors('Error validating channel') def api_validate_channel(): """Validate if a channel exists or can be created""" data = request.get_json() if not data or 'name' not in data: return jsonify({'error': 'Channel name is required'}), 400 channel_name = data['name'] # Check if channel exists channel_num = self._get_channel_number(channel_name) return jsonify({ 'exists': channel_num is not None, 'channel_num': channel_num }) @self.app.route('/api/channels/', methods=['PUT']) @self._api_errors('Error updating channel') def api_update_channel(channel_idx): """Update channel name or configuration""" data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 # This would use channel_manager return jsonify({'success': True, 'message': 'Channel update requires bot connection'}) @self.app.route('/api/channels/stats') @self._api_errors('Error getting channel stats') def api_channel_stats(): """Get channel statistics and usage data""" stats = self._get_channel_statistics() return jsonify(stats) @self.app.route('/api/channels//feeds') @self._api_errors('Error getting channel feeds') def api_channel_feeds(channel_idx): """Get all feed subscriptions for a specific channel""" feeds = self._get_feeds_by_channel(channel_idx) return jsonify({'feeds': feeds}) @self.app.route('/api/radio/status') @self._api_errors('Error getting radio status') def api_radio_status(): """Current radio connection state from bot_metadata.""" value = self.db_manager.get_metadata('radio_connected') connected = value == '1' if value is not None else None return jsonify({'connected': connected, 'status_known': value is not None}) @self.app.route('/api/radio/reboot', methods=['POST']) @self._api_errors('Error queuing radio reboot') def api_radio_reboot(): """Queue a radio reboot (disconnect + reconnect).""" op_id = self._queue_operation('radio_reboot') return jsonify({'success': True, 'operation_id': op_id, 'message': 'Radio reboot queued'}) @self.app.route('/api/radio/connect', methods=['POST']) @self._api_errors('Error queuing radio connect/disconnect') def api_radio_connect(): """Queue radio connect or disconnect. Body: {'action': 'connect'|'disconnect'}""" data = request.get_json(silent=True) or {} action = data.get('action', '') if action not in ('connect', 'disconnect'): return jsonify({'error': "action must be 'connect' or 'disconnect'"}), 400 op_type = 'radio_connect' if action == 'connect' else 'radio_disconnect' op_id = self._queue_operation(op_type) return jsonify({'success': True, 'pending': True, 'operation_id': op_id}) @self.app.route('/api/radio/firmware/config/read', methods=['POST']) @self._api_errors('Error queuing firmware read') def api_firmware_config_read(): """Queue a firmware config read (path hash mode). Poll /api/channel-operations/.""" op_id = self._queue_operation('firmware_read') return jsonify({'success': True, 'operation_id': op_id}) @self.app.route('/api/radio/firmware/config/write', methods=['POST']) @self._api_errors('Error queuing firmware write') def api_firmware_config_write(): """Queue a firmware config write. Body may carry ``path_hash_mode`` and/or ``default_flood_scope`` (a region name, or empty/null to clear the radio's default). Poll /api/channel-operations/.""" data = request.get_json(silent=True) or {} allowed = {'path_hash_mode', 'default_flood_scope'} payload = {k: v for k, v in data.items() if k in allowed} if not payload: return jsonify({ 'error': 'No valid fields provided ' '(path_hash_mode, default_flood_scope)' }), 400 if 'path_hash_mode' in payload: mode = int(payload['path_hash_mode']) if not (0 <= mode <= 2): return jsonify({'error': 'path_hash_mode must be 0-2 (bytes per hop = mode + 1)'}), 400 payload['path_hash_mode'] = mode if 'default_flood_scope' in payload: raw = str(payload['default_flood_scope'] or '').strip() try: # A global marker means "no default scope", which the # radio spells as a cleared field, so both arrive here # as the empty string. canonical = ( '' if flood_scope.is_global_marker(raw) else flood_scope.validate_device_scope_name(raw) ) except ValueError as exc: return jsonify({'error': str(exc)}), 400 payload['default_flood_scope'] = canonical op_id = self._queue_operation('firmware_write', payload) return jsonify({'success': True, 'operation_id': op_id}) @self.app.route('/api/radio/params', methods=['GET']) @self._api_errors('Error queuing radio params read') def api_radio_params_read(): """Queue a radio parameter read (freq, bw, sf, cr, tx_power). Poll /api/channel-operations/.""" op_id = self._queue_operation('radio_params_read') return jsonify({'success': True, 'operation_id': op_id}) @self.app.route('/api/radio/params', methods=['POST']) @self._api_errors('Error queuing radio params write') def api_radio_params_write(): """Queue a radio/node parameter write. Body may mix: freq/bw/sf/cr (together), tx_power, name, lat/lon (together), adv_loc_policy, multi_acks, telemetry_mode_base/loc/env, and rx_delay/airtime_factor (together). manual_add_contacts is deliberately not writable here — it is owned by [Bot] auto_manage_contacts in config.ini. Poll /api/channel-operations/ for result.""" data = request.get_json(silent=True) or {} allowed = { 'freq', 'bw', 'sf', 'cr', 'tx_power', 'name', 'lat', 'lon', 'adv_loc_policy', 'multi_acks', 'telemetry_mode_base', 'telemetry_mode_loc', 'telemetry_mode_env', 'rx_delay', 'airtime_factor', } payload = {k: v for k, v in data.items() if k in allowed} if not payload: return jsonify({'error': f"No valid fields (expected one of: {', '.join(sorted(allowed))})"}), 400 if 'freq' in payload: freq = float(payload['freq']) if not (100.0 <= freq <= 1700.0): return jsonify({'error': 'freq must be 100–1700 MHz'}), 400 payload['freq'] = freq if 'bw' in payload: bw = float(payload['bw']) if bw not in (62.5, 125.0, 250.0, 500.0): return jsonify({'error': 'bw must be 62.5, 125, 250, or 500 kHz'}), 400 payload['bw'] = bw if 'sf' in payload: sf = int(payload['sf']) if not (5 <= sf <= 12): return jsonify({'error': 'sf must be 5–12'}), 400 payload['sf'] = sf if 'cr' in payload: cr = int(payload['cr']) if not (5 <= cr <= 8): return jsonify({'error': 'cr must be 5–8'}), 400 payload['cr'] = cr if 'tx_power' in payload: tx = int(payload['tx_power']) if not (1 <= tx <= 30): return jsonify({'error': 'tx_power must be 1–30 dBm'}), 400 payload['tx_power'] = tx if 'name' in payload: name = str(payload['name']).strip() if not name or len(name.encode('utf-8')) > 32: return jsonify({'error': 'name must be 1–32 bytes'}), 400 payload['name'] = name if ('lat' in payload) != ('lon' in payload): return jsonify({'error': 'lat and lon must be provided together'}), 400 if 'lat' in payload: lat = float(payload['lat']) lon = float(payload['lon']) if not (-90.0 <= lat <= 90.0): return jsonify({'error': 'lat must be -90 to 90'}), 400 if not (-180.0 <= lon <= 180.0): return jsonify({'error': 'lon must be -180 to 180'}), 400 payload['lat'] = lat payload['lon'] = lon if 'adv_loc_policy' in payload: policy = int(payload['adv_loc_policy']) if policy not in (0, 1): return jsonify({'error': 'adv_loc_policy must be 0 (private) or 1 (share)'}), 400 payload['adv_loc_policy'] = policy if 'multi_acks' in payload: acks = int(payload['multi_acks']) if not (0 <= acks <= 3): return jsonify({'error': 'multi_acks must be 0–3'}), 400 payload['multi_acks'] = acks for telem_key in ('telemetry_mode_base', 'telemetry_mode_loc', 'telemetry_mode_env'): if telem_key in payload: mode = int(payload[telem_key]) if not (0 <= mode <= 2): return jsonify({'error': f'{telem_key} must be 0 (deny), 1 (per-contact), or 2 (allow all)'}), 400 payload[telem_key] = mode if ('rx_delay' in payload) != ('airtime_factor' in payload): return jsonify({'error': 'rx_delay and airtime_factor must be provided together'}), 400 if 'rx_delay' in payload: rx_delay = float(payload['rx_delay']) airtime_factor = float(payload['airtime_factor']) if not (0.0 <= rx_delay <= 20.0): return jsonify({'error': 'rx_delay must be 0–20 seconds'}), 400 if not (0.0 <= airtime_factor <= 9.0): return jsonify({'error': 'airtime_factor must be 0–9'}), 400 payload['rx_delay'] = rx_delay payload['airtime_factor'] = airtime_factor radio_fields = {'freq', 'bw', 'sf', 'cr'} if radio_fields & set(payload) and not radio_fields <= set(payload): return jsonify({'error': 'freq, bw, sf, and cr must all be provided together'}), 400 op_id = self._queue_operation('radio_params_write', payload) return jsonify({'success': True, 'operation_id': op_id}) @self.app.route('/api/radio/advert', methods=['POST']) @self._api_errors('Error queuing radio advert') def api_radio_advert(): """Queue a self-advertisement. Body: {flood: bool} (default false = zero-hop). Poll /api/channel-operations/ for result.""" data = request.get_json(silent=True) or {} flood = data.get('flood', False) if not isinstance(flood, bool): return jsonify({'error': 'flood must be true or false'}), 400 op_id = self._queue_operation('radio_advert', {'flood': flood}) return jsonify({'success': True, 'operation_id': op_id}) def _get_bot_uptime(self): """Get bot uptime in seconds from database""" try: # Get start time from database metadata start_time = self.db_manager.get_bot_start_time() if start_time: return int(time.time() - start_time) else: # Fallback: try to get earliest message timestamp with self._with_db_connection() as conn: cursor = conn.cursor() # Try to get earliest message timestamp as fallback cursor.execute(""" SELECT MIN(timestamp) FROM message_stats WHERE timestamp IS NOT NULL """) result = cursor.fetchone() if result and result[0]: return int(time.time() - result[0]) return 0 except Exception as e: self.logger.debug(f"Could not get bot start time from database: {e}") return 0 def _decode_path_hex(self, path_hex: str, bytes_per_hop: int | None = None) -> list[dict[str, Any]]: """Decode a hex path string to repeater nodes. Thin wrapper over the shared engine (modules.path_inference.decode_path_nodes); the bot `path` command and /api/mesh/resolve-path use the same implementation. """ from modules.path_inference import decode_path_nodes return decode_path_nodes( path_hex, bytes_per_hop, config=self.config, db_manager=self.db_manager, logger=self.logger, mesh_graph=self._get_mesh_graph(), ) def run(self, host='127.0.0.1', port=8080, debug=False): """Run the modern web viewer""" self.logger.info(f"Starting modern web viewer on {host}:{port}") self._suppress_werkzeug_headers_error() try: self.socketio.run( self.app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True ) except Exception as e: self.logger.error(f"Error running web viewer: {e}") raise @staticmethod def _suppress_werkzeug_headers_error() -> None: """Install a log filter that silences the 'Headers already set' AssertionError. Werkzeug's dev server catches this internally and continues serving, but it logs a full traceback at ERROR level. The underlying cause (concurrent SocketIO polling requests racing through the WSGI layer) is reduced by the single-socket-per-page fix, but may still occur occasionally. The filter downgrades these specific records to DEBUG so they don't alarm operators. """ import logging class _HeadersAlreadySetFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: msg = record.getMessage() return "Headers already set" not in msg for name in ("werkzeug", "werkzeug.serving"): logging.getLogger(name).addFilter(_HeadersAlreadySetFilter()) def main(): """Entry point for the meshcore-viewer command""" import argparse parser = argparse.ArgumentParser(description='MeshCore Bot Data Viewer') parser.add_argument('--host', default='127.0.0.1', help='Host to bind to') parser.add_argument('--port', type=int, default=8080, help='Port to bind to') parser.add_argument('--debug', action='store_true', help='Enable debug mode') parser.add_argument( "--config", default="config.ini", help="Path to configuration file (default: config.ini)", ) args = parser.parse_args() viewer = BotDataViewer(config_path=args.config) viewer.run(host=args.host, port=args.port, debug=args.debug) if __name__ == '__main__': main()