From a29d3ce0ac6539d15ffa78b33b8fb695952cfa61 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Sat, 14 Feb 2026 19:19:09 -0600 Subject: [PATCH] Improve propagation sync and improve markdown rendering - Added methods to collect and manage propagation sync metrics in ReticulumMeshChat, improving message tracking and delivery confirmation. - Updated frontend components to display detailed sync status notifications, including stored messages and delivery confirmations. - Implemented URL safety checks in the markdown renderer to prevent XSS vulnerabilities by sanitizing links and image sources. - Refactored various comments and code for clarity and maintainability across multiple files. --- electron/main-legacy.js | 5 +- electron/main.js | 5 +- meshchatx/meshchat.py | 115 +++++++++++++++--- meshchatx/src/backend/async_utils.py | 4 +- meshchatx/src/backend/database/messages.py | 11 ++ meshchatx/src/backend/database/schema.py | 2 - meshchatx/src/backend/docs_manager.py | 8 +- .../interfaces/WebsocketClientInterface.py | 1 - .../interfaces/WebsocketServerInterface.py | 6 - meshchatx/src/backend/markdown_renderer.py | 36 +++++- meshchatx/src/backend/telephone_manager.py | 6 - meshchatx/src/frontend/components/App.vue | 8 +- .../src/frontend/components/call/CallPage.vue | 1 - meshchatx/src/frontend/js/LinkUtils.js | 3 +- meshchatx/src/frontend/js/MarkdownRenderer.js | 11 +- meshchatx/src/frontend/js/MicronParser.js | 3 +- meshchatx/src/frontend/js/Utils.js | 3 +- 17 files changed, 169 insertions(+), 59 deletions(-) diff --git a/electron/main-legacy.js b/electron/main-legacy.js index f6e9528..86d44e7 100644 --- a/electron/main-legacy.js +++ b/electron/main-legacy.js @@ -301,8 +301,7 @@ app.whenReady().then(async () => { } } - // find path to python/cxfreeze reticulum meshchatx executable - // Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat + // find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX) const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX"; // get app path (handles both development and packaged app) @@ -362,7 +361,7 @@ app.whenReady().then(async () => { const requiredArguments = [ "--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron "--port", - "9337", // FIXME: let system pick a random unused port? + "9337", // '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog ]; diff --git a/electron/main.js b/electron/main.js index fecd723..bd1065e 100644 --- a/electron/main.js +++ b/electron/main.js @@ -458,8 +458,7 @@ app.whenReady().then(async () => { } } - // find path to python/cxfreeze reticulum meshchatx executable - // Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat + // find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX) const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX"; // get app path (handles both development and packaged app) @@ -524,7 +523,7 @@ app.whenReady().then(async () => { const requiredArguments = [ "--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron "--port", - "9337", // FIXME: let system pick a random unused port? + "9337", // '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog ]; diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index d91f7fc..a2938dd 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -279,6 +279,7 @@ class ReticulumMeshChat: # Multi-identity support self.contexts: dict[str, IdentityContext] = {} self.current_context: IdentityContext | None = None + self._propagation_sync_metrics: dict[str, dict] = {} self.setup_identity(identity) self.web_audio_bridge = WebAudioBridge(None, None) @@ -819,7 +820,6 @@ class ReticulumMeshChat: self._identity_session_id += 1 # Teardown current identity state and managers - # This also calls cleanup_rns_state_for_identity self.teardown_identity() # Give loops a moment to finish @@ -1684,6 +1684,94 @@ class ReticulumMeshChat: return ctx.message_router.cancel_propagation_node_requests() + def _get_propagation_sync_metrics(self, context=None): + ctx = context or self.current_context + if not ctx: + return None + + key = ctx.identity_hash + if key not in self._propagation_sync_metrics: + self._propagation_sync_metrics[key] = { + "started_at": None, + "baseline_total_messages": 0, + "baseline_delivered_messages": 0, + "messages_stored": 0, + "delivery_confirmations": 0, + "messages_hidden": 0, + } + + return self._propagation_sync_metrics[key] + + def _begin_propagation_sync_metrics(self, context=None): + ctx = context or self.current_context + if not ctx or not ctx.database: + return + + metrics = self._get_propagation_sync_metrics(context=ctx) + if metrics is None: + return + + metrics["started_at"] = datetime.now(UTC).isoformat() + metrics["baseline_total_messages"] = ctx.database.messages.count_lxmf_messages() + metrics["baseline_delivered_messages"] = ( + ctx.database.messages.count_lxmf_messages_by_state("delivered") + ) + metrics["messages_stored"] = 0 + metrics["delivery_confirmations"] = 0 + metrics["messages_hidden"] = 0 + + def _collect_propagation_sync_metrics(self, context=None): + ctx = context or self.current_context + if not ctx or not ctx.database: + return { + "messages_stored": 0, + "delivery_confirmations": 0, + "messages_hidden": 0, + } + + metrics = self._get_propagation_sync_metrics(context=ctx) + if metrics is None: + return { + "messages_stored": 0, + "delivery_confirmations": 0, + "messages_hidden": 0, + } + if metrics["started_at"] is None: + return { + "messages_stored": 0, + "delivery_confirmations": 0, + "messages_hidden": 0, + } + + messages_received = ctx.message_router.propagation_transfer_last_result or 0 + current_total_messages = ctx.database.messages.count_lxmf_messages() + current_delivered_messages = ctx.database.messages.count_lxmf_messages_by_state( + "delivered", + ) + + messages_stored = max( + current_total_messages - metrics["baseline_total_messages"], + 0, + ) + delivery_confirmations = max( + current_delivered_messages - metrics["baseline_delivered_messages"], + 0, + ) + messages_hidden = max( + messages_received - messages_stored - delivery_confirmations, + 0, + ) + + metrics["messages_stored"] = messages_stored + metrics["delivery_confirmations"] = delivery_confirmations + metrics["messages_hidden"] = messages_hidden + + return { + "messages_stored": messages_stored, + "delivery_confirmations": delivery_confirmations, + "messages_hidden": messages_hidden, + } + # stops and removes the active propagation node def remove_active_propagation_node(self, context=None): ctx = context or self.current_context @@ -2125,12 +2213,7 @@ class ReticulumMeshChat: sys.exit(code) def get_routes(self): - # This is a bit of a hack to get the routes without running the full server - # It's mainly for testing purposes routes = web.RouteTableDef() - - # We need to mock some things that are usually setup in run() - # but for just getting the route handlers, we can skip most of it self._define_routes(routes) return routes @@ -2378,7 +2461,6 @@ class ReticulumMeshChat: ) result = self.database.restore_database(path) - # Note: This might require an app relaunch to be fully effective return web.json_response( {"status": "success", "result": result, "requires_relaunch": True}, ) @@ -3263,7 +3345,6 @@ class ReticulumMeshChat: InterfaceEditor.update_value(interface_details, data, "callsign") InterfaceEditor.update_value(interface_details, data, "ssid") - # FIXME: move to own sections # RNode Airtime limits and station ID InterfaceEditor.update_value(interface_details, data, "callsign") InterfaceEditor.update_value(interface_details, data, "id_interval") @@ -6055,6 +6136,7 @@ class ReticulumMeshChat: @routes.get("/api/v1/lxmf/propagation-node/status") async def propagation_node_status(request): + sync_metrics = self._collect_propagation_sync_metrics() return web.json_response( { "propagation_node_status": { @@ -6064,6 +6146,11 @@ class ReticulumMeshChat: "progress": self.message_router.propagation_transfer_progress * 100, # convert to percentage "messages_received": self.message_router.propagation_transfer_last_result, + "messages_stored": sync_metrics["messages_stored"], + "delivery_confirmations": sync_metrics[ + "delivery_confirmations" + ], + "messages_hidden": sync_metrics["messages_hidden"], }, }, ) @@ -8893,6 +8980,8 @@ class ReticulumMeshChat: if not ctx: return + self._begin_propagation_sync_metrics(context=ctx) + # update last synced at timestamp ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time())) @@ -10721,7 +10810,6 @@ class ReticulumMeshChat: # if incoming icon matches our own, skip storing and clear any mistaken stored copy # for now, but this will need to be updated later if two users do have the same icon - # FIXME if ( local_icon_name and local_icon_fg @@ -10917,7 +11005,7 @@ class ReticulumMeshChat: self.send_failed_message_via_propagation_node(lxmf_message, context=context) # update state - self.on_lxmf_sending_state_updated(lxmf_message) + self.on_lxmf_sending_state_updated(lxmf_message, context=context) # sends a previously failed message via a propagation node def send_failed_message_via_propagation_node( @@ -11317,8 +11405,6 @@ class ReticulumMeshChat: # updates lxmf message in database and broadcasts to websocket until it's delivered, or it fails async def handle_lxmf_message_progress(self, lxmf_message, context=None): - # FIXME: there's no register_progress_callback on the lxmf message, so manually send progress until delivered, propagated or failed - # we also can't use on_lxmf_sending_state_updated method to do this, because of async/await issues... ctx = context or self.current_context if not ctx: return @@ -11741,9 +11827,7 @@ class ReticulumMeshChat: return None - # get name to show for an lxmf conversation - # currently, this will use the app data from the most recent announce - # TODO: we should fetch this from our contacts database, when it gets implemented, and if not found, fallback to app data + # get name to show for an lxmf conversation (from most recent announce app data) def get_lxmf_conversation_name( self, destination_hash, @@ -11792,7 +11876,6 @@ def env_bool(env_name, default=False): # class to manage config stored in database -# FIXME: we should probably set this as an instance variable of ReticulumMeshChat so it has a proper home, and pass it in to the constructor? def main(): # apply asyncio 3.13 patch if needed AsyncUtils.apply_asyncio_313_patch() diff --git a/meshchatx/src/backend/async_utils.py b/meshchatx/src/backend/async_utils.py index fc053dc..e513571 100644 --- a/meshchatx/src/backend/async_utils.py +++ b/meshchatx/src/backend/async_utils.py @@ -16,9 +16,7 @@ class AsyncUtils: if sys.version_info >= (3, 13): import asyncio.base_events - # We need to patch the loop's sendfile to raise NotImplementedError for SSL transports. - # This will force aiohttp to use its own fallback which works correctly. - + # Patch sendfile so aiohttp uses its SSL fallback on 3.13. original_sendfile = asyncio.base_events.BaseEventLoop.sendfile async def patched_sendfile( diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py index cca5024..2481ba9 100644 --- a/meshchatx/src/backend/database/messages.py +++ b/meshchatx/src/backend/database/messages.py @@ -86,6 +86,17 @@ class MessageDAO: def get_all_lxmf_messages(self): return self.provider.fetchall("SELECT * FROM lxmf_messages") + def count_lxmf_messages(self): + row = self.provider.fetchone("SELECT COUNT(*) AS count FROM lxmf_messages") + return row["count"] if row and row["count"] is not None else 0 + + def count_lxmf_messages_by_state(self, state): + row = self.provider.fetchone( + "SELECT COUNT(*) AS count FROM lxmf_messages WHERE state = ?", + (state,), + ) + return row["count"] if row and row["count"] is not None else 0 + def get_conversation_messages(self, destination_hash, limit=100, offset=0): return self.provider.fetchall( "SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py index 6ba5e85..bbe9b75 100644 --- a/meshchatx/src/backend/database/schema.py +++ b/meshchatx/src/backend/database/schema.py @@ -157,8 +157,6 @@ class DatabaseSchema: # Other essential tables that were present from version 1 # Peewee automatically creates tables if they don't exist. - # Here we define the full schema for all tables as they should be now. - tables = { "announces": """ CREATE TABLE IF NOT EXISTS announces ( diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py index bc41803..5e56e5a 100644 --- a/meshchatx/src/backend/docs_manager.py +++ b/meshchatx/src/backend/docs_manager.py @@ -201,9 +201,7 @@ class DocsManager: # Also try in the public directory search_paths.append(os.path.join(self.public_dir, "meshchatx-docs")) - # Also try relative to this file - # This file is in meshchatx/src/backend/docs_manager.py - # Project root is 3 levels up + # Also try relative to this file (project root 3 levels up) this_dir = os.path.dirname(os.path.abspath(__file__)) search_paths.append( os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")), @@ -407,8 +405,7 @@ class DocsManager: if f"_{lang}.html" in file: target_files.append(os.path.join(root, file)) else: - # For English, we want files that DON'T have a language suffix - # This is a bit heuristic + # English: no language suffix; other langs use _.html has_lang_suffix = False for lang_code in known_langs: if f"_{lang_code}.html" in file: @@ -634,7 +631,6 @@ class DocsManager: else: shutil.copy2(s, d) else: - # Just extract everything directly to version_dir, but remove root folder if exists zip_ref.extractall(temp_extract) src_path = os.path.join(temp_extract, root_folder) if os.path.exists(src_path) and os.path.isdir(src_path): diff --git a/meshchatx/src/backend/interfaces/WebsocketClientInterface.py b/meshchatx/src/backend/interfaces/WebsocketClientInterface.py index 9f4f957..95a5210 100644 --- a/meshchatx/src/backend/interfaces/WebsocketClientInterface.py +++ b/meshchatx/src/backend/interfaces/WebsocketClientInterface.py @@ -8,7 +8,6 @@ from websockets.sync.connection import Connection class WebsocketClientInterface(Interface): - # TODO: required? DEFAULT_IFAC_SIZE = 16 RECONNECT_DELAY_SECONDS = 5 diff --git a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py index 4695ef8..40b3f04 100644 --- a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py +++ b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py @@ -11,7 +11,6 @@ from meshchatx.src.backend.interfaces.WebsocketClientInterface import ( class WebsocketServerInterface(Interface): - # TODO: required? DEFAULT_IFAC_SIZE = 16 RESTART_DELAY_SECONDS = 5 @@ -63,7 +62,6 @@ class WebsocketServerInterface(Interface): def clients(self): return len(self.spawned_interfaces) - # TODO docs def received_announce(self, from_spawned=False): if from_spawned: self.ia_freq_deque.append(time.time()) @@ -105,14 +103,10 @@ class WebsocketServerInterface(Interface): spawned_interface.parent_interface = self spawned_interface.online = True - # TODO implement? spawned_interface.announce_rate_target = None spawned_interface.announce_rate_grace = None spawned_interface.announce_rate_penalty = None - # TODO ifac? - # TODO announce rates? - # activate child interface RNS.log( f"Spawned new WebsocketClientInterface: {spawned_interface}", diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py index 7b0ecd4..1c99b9d 100644 --- a/meshchatx/src/backend/markdown_renderer.py +++ b/meshchatx/src/backend/markdown_renderer.py @@ -1,6 +1,22 @@ import html import re +_SAFE_LINK_PREFIXES = ("https://", "http://", "/", "#", "mailto:") +_UNSAFE_PROTOCOLS = ("javascript:", "data:", "vbscript:", "file:") + + +def _safe_href(url): + if not url or not isinstance(url, str): + return "#" + u = url.strip().lower() + if any(u.startswith(p) for p in _UNSAFE_PROTOCOLS): + return "#" + if any(u.startswith(p) for p in _SAFE_LINK_PREFIXES): + return url + if ":" in u.split("/")[0]: + return "#" + return url + class MarkdownRenderer: """A simple Markdown to HTML renderer.""" @@ -101,17 +117,29 @@ class MarkdownRenderer: flags=re.MULTILINE, ) - # Links + # Links (href sanitized to prevent javascript:/data: XSS) + def link_repl(match): + label, url = match.group(1), match.group(2) + safe_url = _safe_href(url) + return f'{label}' + text = re.sub( r"\[([^\]]+)\]\(([^)]+)\)", - r'\1', + link_repl, text, ) - # Images + # Images (src sanitized) + def img_repl(match): + alt, src = match.group(1), match.group(2) + safe_src = _safe_href(src) + if safe_src == "#": + return html.escape(match.group(0)) + return f'
{alt}
' + text = re.sub( r"!\[([^\]]*)\]\(([^)]+)\)", - r'
\1
', + img_repl, text, ) diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 4431a12..b093b1b 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -79,10 +79,6 @@ class TelephoneManager: @property def is_recording(self): - # Check if voicemail manager or this manager is recording - # This is a bit of a hack since we don't have a direct link to voicemail_manager here - # but we can check if our own recording is active if we had it. - # For now, we'll just return False and let meshchat.py handle the combined status. return False def init_telephone(self): @@ -138,8 +134,6 @@ class TelephoneManager: def on_telephone_ringing(self, caller_identity: RNS.Identity): if self.initiation_status: - # This is an outgoing call where the remote side is now ringing. - # We update the initiation status to "Ringing..." for the UI. self._update_initiation_status("Ringing...") return diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 87a33d9..05ec0c1 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -1094,8 +1094,14 @@ export default { // show result const status = this.propagationNodeStatus?.state; const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0; + const messagesStored = this.propagationNodeStatus?.messages_stored ?? 0; + const deliveryConfirmations = + this.propagationNodeStatus?.delivery_confirmations ?? 0; + const messagesHidden = this.propagationNodeStatus?.messages_hidden ?? 0; if (status === "complete" || status === "idle") { - ToastUtils.success(this.$t("app.sync_complete", { count: messagesReceived })); + const base = this.$t("app.sync_complete", { count: messagesReceived }); + const details = `${messagesStored} stored, ${deliveryConfirmations} confirmations, ${messagesHidden} hidden`; + ToastUtils.success(`${base} (${details})`); } else { ToastUtils.error(this.$t("app.sync_error", { status: status })); } diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 2612814..8c108fd 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -3223,7 +3223,6 @@ export default { } }, async playVoicemail(voicemail) { - // This is now handled by AudioWaveformPlayer, but we keep it for backward compatibility or direct calls if (this.playingVoicemailId === voicemail.id) { if (this.audioPlayer) { this.audioPlayer.pause(); diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js index 4d939e8..b9a1faa 100644 --- a/meshchatx/src/frontend/js/LinkUtils.js +++ b/meshchatx/src/frontend/js/LinkUtils.js @@ -8,8 +8,7 @@ export default class LinkUtils { // Hash is 32 hex chars. Path is optional (NomadNet only). const hashPattern = "[a-fA-F0-9]{32}"; - // This matches optional prefixes (nomadnet://, nomadnet@, lxmf://, lxmf@) - // followed by a 32-char hex hash, and an optional path starting with : + // Optional prefix (nomadnet://, nomadnet@, lxmf://, lxmf@), then hash, optional path. const reticulumRegex = new RegExp( `(nomadnet://|nomadnet@|lxmf://|lxmf@)?(${hashPattern})(?::(/[\\w\\d./?%&=-]*))?`, "g" diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js index da38543..0b37e57 100644 --- a/meshchatx/src/frontend/js/MarkdownRenderer.js +++ b/meshchatx/src/frontend/js/MarkdownRenderer.js @@ -7,11 +7,13 @@ export default class MarkdownRenderer { * Ported and simplified from meshchatx/src/backend/markdown_renderer.py */ static render(text) { - if (!text) { + if (text == null) { return ""; } + if (typeof text !== "string") { + text = String(text); + } - // Escape HTML entities first to prevent XSS text = Utils.escapeHtml(text); // Fenced code blocks - process these FIRST and replace with placeholders @@ -81,9 +83,12 @@ export default class MarkdownRenderer { * Strips markdown from text for previews. */ static strip(text) { - if (!text) { + if (text == null) { return ""; } + if (typeof text !== "string") { + text = String(text); + } // Strip fenced code blocks text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, "[Code Block]"); diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js index 169724e..21b45ae 100644 --- a/meshchatx/src/frontend/js/MicronParser.js +++ b/meshchatx/src/frontend/js/MicronParser.js @@ -115,9 +115,10 @@ class MicronParser { } convertMicronToHtml(markup) { + if (markup == null) return ""; + if (typeof markup !== "string") markup = String(markup); let html = ""; - // parse header tags for page-level color defaults const headerColors = this.parseHeaderTags(markup); const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG }; diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.js index ec905c0..6c5823c 100644 --- a/meshchatx/src/frontend/js/Utils.js +++ b/meshchatx/src/frontend/js/Utils.js @@ -197,7 +197,8 @@ class Utils { } static escapeHtml(text) { - if (!text) return ""; + if (text == null) return ""; + text = String(text); const map = { "&": "&", "<": "<",