Improve propagation sync and improve markdown rendering
CI / build-frontend (push) Failing after 18s
CI / lint (push) Failing after 1m45s
CI / test-backend (push) Successful in 42s
CI / test-lang (push) Failing after 19s
Build and Publish Docker Image / build (push) Failing after 2m43s
Build and Publish Docker Image / build-dev (push) Failing after 49s
Build Test / Build and Test (push) Failing after 7m0s
Tests / test (push) Failing after 18s
Security Scans / scan (push) Failing after 48s

- 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.
This commit is contained in:
Sudo-Ivan
2026-02-14 19:19:09 -06:00
parent 21450c69eb
commit a29d3ce0ac
17 changed files with 169 additions and 59 deletions
+2 -3
View File
@@ -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
];
+2 -3
View File
@@ -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
];
+99 -16
View File
@@ -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()
+1 -3
View File
@@ -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(
@@ -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 ?",
-2
View File
@@ -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 (
+2 -6
View File
@@ -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 _<lang>.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):
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5
@@ -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}",
+32 -4
View File
@@ -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'<a href="{html.escape(safe_url)}" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">{label}</a>'
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
r'<a href="\2" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank">\1</a>',
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'<div class="my-6"><img src="{html.escape(safe_src)}" alt="{alt}" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>'
text = re.sub(
r"!\[([^\]]*)\]\(([^)]+)\)",
r'<div class="my-6"><img src="\2" alt="\1" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>',
img_repl,
text,
)
@@ -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
+7 -1
View File
@@ -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 }));
}
@@ -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();
+1 -2
View File
@@ -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"
@@ -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]");
+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -197,7 +197,8 @@ class Utils {
}
static escapeHtml(text) {
if (!text) return "";
if (text == null) return "";
text = String(text);
const map = {
"&": "&amp;",
"<": "&lt;",