Files
meshcore-bot/modules/web_viewer/app.py
T
agessaman 2a1c2f86d8 feat(web_viewer): rebuild Real-time as one tape of what the radio heard
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened.

A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet.

Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds.

Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them.

The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
2026-10-17 19:40:23 -07:00

4867 lines
229 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.<name>
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,
field_id,
to_config_string,
validate_field,
)
from modules.settings_store import get_settings_store
from modules.template_reference import render_preview
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.<name> (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 ``<local_dir_path>/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 ``<prefix>.<field>`` 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 ``<prefix>.<field>``; 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 ``"<message>: <error>"`` 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/<id> 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 is admin-only: its live socket
# is, so an anonymous visit would only ever show an empty page.
_PUBLIC_PAGE_PATHS = frozenset([
'/', '/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; "
"style-src 'self' 'unsafe-inline' "
"https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
"img-src 'self' data: blob: https://*.tile.openstreetmap.org "
"https://tiles.openfreemap.org "
"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; "
# 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"
)
# 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/<kind>/<name>', 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']
# Fields are addressed by field_id (key, or key@Section for a shared
# section). A bare key still resolves when only one field has it, so
# scripted saves written before field ids keep working.
schema_by_id = {field_id(f): f for f in entry['fields']}
key_counts: dict[str, int] = {}
for f in entry['fields']:
key_counts[f['key']] = key_counts.get(f['key'], 0) + 1
for f in entry['fields']:
if key_counts[f['key']] == 1:
schema_by_id.setdefault(f['key'], f)
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 submitted, val in raw_values.items():
field = schema_by_id.get(submitted)
if field is not None:
key = field['key']
ok, coerced, err = validate_field(field, val)
if not ok:
errors[submitted] = err
else:
tsec = field.get('section') or section
if field.get('type') in ('int', 'float', 'enum') and coerced == '':
# A cleared number, or an enum's "" (inherit) option,
# means unset: `key =` would make getint/getfloat
# raise, or read as an invalid choice.
deletes.setdefault(tsec, []).append(key)
else:
updates.setdefault(tsec, {})[key] = to_config_string(field, coerced)
else:
updates[section][str(submitted)] = '' 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
if field.get('type') in ('int', 'float', 'enum') and coerced == '':
# Unset, as for top-level fields; leaving it out of
# `written` deletes any existing key below.
continue
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
# (kind, name, key) -> template spec. Specs are class attributes, so they
# don't change with config and needn't be rediscovered on every keystroke.
template_specs: dict[tuple[str, str, str], dict | None] = {}
@self.app.route('/api/plugins/<kind>/<name>/template-preview', methods=['POST'])
def api_plugins_template_preview(kind: str, name: str):
"""Render a piped template field against sample messages.
Body: ``{"key": str, "template": str}``. Nothing is saved.
"""
data = request.get_json(silent=True) or {}
key = str(data.get('key', ''))
template = data.get('template', '')
if not isinstance(template, str) or len(template) > 2000:
return jsonify({'error': 'Template must be text of at most 2000 characters'}), 400
cache_key = (kind, name, key)
if cache_key not in template_specs:
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"),
)
for entry in view:
for field in entry['fields']:
if field.get('template'):
template_specs[(entry['kind'], entry['name'], field['key'])] = field['template']
template_specs.setdefault(cache_key, None)
spec = template_specs[cache_key]
if not spec or not spec.get('previewable'):
return jsonify({'error': 'This field has no preview'}), 404
try:
return jsonify(render_preview(spec, template, self.config))
except Exception:
self.logger.exception("Error rendering template preview")
return jsonify({'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": <int>|"all", "tables": [<name>, ...] 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": {<table>: <count>, ...}} — 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'] = '</api/dashboard/summary>; 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.<channel>``),
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/<id> 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/<int:feed_id>')
@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/<int:feed_id>', 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/<int:feed_id>', 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/<int:feed_id>/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/<int:feed_id>/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/<int:feed_id>/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/<int:feed_id>/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/<int:channel_idx>', 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/<int:operation_id>', 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/<int:channel_idx>', 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/<int:channel_idx>/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/<id>."""
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/<id>."""
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/<id>."""
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/<id> 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/<id> 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()