diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 447ab33..1fa030c 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -5946,6 +5946,63 @@ class ReticulumMeshChat: }, ) + @routes.get("/api/v1/telephone/contacts/export") + async def telephone_contacts_export(request): + try: + rows = self.database.contacts.get_contacts(limit=10000, offset=0) + export_data = [] + for row in rows: + d = dict(row) + for k in ("id", "created_at", "updated_at"): + d.pop(k, None) + export_data.append(d) + return web.json_response({"contacts": export_data}) + except Exception as e: + return web.json_response( + {"message": f"Failed to export contacts: {e!s}"}, + status=500, + ) + + @routes.post("/api/v1/telephone/contacts/import") + async def telephone_contacts_import(request): + try: + data = await request.json() + contacts = data.get("contacts", []) + if not isinstance(contacts, list): + return web.json_response( + {"message": "Invalid import format: contacts must be an array"}, + status=400, + ) + added = 0 + skipped = 0 + for c in contacts: + name = c.get("name") + remote_identity_hash = c.get("remote_identity_hash") + if not name or not remote_identity_hash: + skipped += 1 + continue + try: + self.database.contacts.add_contact( + name, + remote_identity_hash, + lxmf_address=c.get("lxmf_address"), + lxst_address=c.get("lxst_address"), + preferred_ringtone_id=c.get("preferred_ringtone_id"), + custom_image=c.get("custom_image"), + is_telemetry_trusted=c.get("is_telemetry_trusted", 0), + ) + added += 1 + except Exception: + skipped += 1 + return web.json_response( + {"message": "Import complete", "added": added, "skipped": skipped}, + ) + except Exception as e: + return web.json_response( + {"message": f"Failed to import contacts: {e!s}"}, + status=500, + ) + # announce @routes.get("/api/v1/announce") async def announce_trigger(request): @@ -7607,11 +7664,20 @@ class ReticulumMeshChat: # get path table @routes.get("/api/v1/path-table") + @routes.post("/api/v1/path-table") async def path_table(request): limit = request.query.get("limit", None) offset = request.query.get("offset", None) + destination_hashes = None + if request.method == "POST": + try: + body = await request.json() + destination_hashes = body.get("destination_hashes") + if destination_hashes and not isinstance(destination_hashes, list): + destination_hashes = None + except Exception: # noqa: S110 + pass - # get path table, making sure hash and via are in hex as json_response can't serialize bytes all_paths = [] if hasattr(self, "reticulum") and self.reticulum: try: @@ -7619,6 +7685,14 @@ class ReticulumMeshChat: except Exception: # noqa: S110 pass + if destination_hashes: + hash_set = set( + h.lower() for h in destination_hashes if isinstance(h, str) + ) + all_paths = [ + p for p in all_paths if p["hash"].hex().lower() in hash_set + ] + total_count = len(all_paths) # apply pagination if requested @@ -9457,6 +9531,22 @@ class ReticulumMeshChat: value = self._parse_bool(data["show_suggested_community_interfaces"]) self.config.show_suggested_community_interfaces.set(value) + for key in ( + "announce_limit_lxmf_delivery", + "announce_limit_nomadnetwork_node", + "announce_limit_lxmf_propagation", + ): + if key in data: + val = data[key] + if val is None or val == "": + getattr(self.config, key).set(None) + else: + try: + v = int(val) + getattr(self.config, key).set(max(0, v) if v >= 0 else None) + except (TypeError, ValueError): + getattr(self.config, key).set(None) + if "lxmf_preferred_propagation_node_destination_hash" in data: # update config value value = data["lxmf_preferred_propagation_node_destination_hash"] @@ -10824,6 +10914,9 @@ class ReticulumMeshChat: "desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(), "desktop_hardware_acceleration_enabled": ctx.config.desktop_hardware_acceleration_enabled.get(), "blackhole_integration_enabled": ctx.config.blackhole_integration_enabled.get(), + "announce_limit_lxmf_delivery": ctx.config.announce_limit_lxmf_delivery.get(), + "announce_limit_nomadnetwork_node": ctx.config.announce_limit_nomadnetwork_node.get(), + "announce_limit_lxmf_propagation": ctx.config.announce_limit_lxmf_propagation.get(), "csp_extra_connect_src": ctx.config.csp_extra_connect_src.get(), "csp_extra_img_src": ctx.config.csp_extra_img_src.get(), "csp_extra_frame_src": ctx.config.csp_extra_frame_src.get(), @@ -11232,8 +11325,20 @@ class ReticulumMeshChat: f"Telemetry request from untrusted peer {source_hash}, ignoring", ) else: - print(f"Responding to telemetry request from {source_hash}") - self.handle_telemetry_request(source_hash) + lat = self.database.config.get("map_default_lat") + lon = self.database.config.get("map_default_lon") + if lat is not None and lon is not None: + print(f"Responding to telemetry request from {source_hash}") + self.handle_telemetry_request(source_hash) + else: + if not hasattr(self, "_telemetry_no_location_warned"): + self._telemetry_no_location_warned = set() + if source_hash not in self._telemetry_no_location_warned: + self._telemetry_no_location_warned.add(source_hash) + print( + f"Cannot respond to telemetry request from {source_hash}: No location set. " + "Set manual coordinates in Settings > Location to respond." + ) self.db_upsert_lxmf_message(lxmf_message, context=ctx) diff --git a/meshchatx/src/backend/announce_manager.py b/meshchatx/src/backend/announce_manager.py index 1d04f62..491c0d5 100644 --- a/meshchatx/src/backend/announce_manager.py +++ b/meshchatx/src/backend/announce_manager.py @@ -2,10 +2,26 @@ import base64 from .database import Database +_ASPECT_LIMIT_KEYS = { + "lxmf.delivery": "announce_limit_lxmf_delivery", + "nomadnetwork.node": "announce_limit_nomadnetwork_node", + "lxmf.propagation": "announce_limit_lxmf_propagation", +} + class AnnounceManager: - def __init__(self, db: Database): + def __init__(self, db: Database, config=None): self.db = db + self.config = config + + def _get_limit_for_aspect(self, aspect): + key = _ASPECT_LIMIT_KEYS.get(aspect) + if not key: + return None + attr = getattr(self.config, key, None) + if attr is None: + return None + return attr.get() def upsert_announce( self, @@ -16,10 +32,24 @@ class AnnounceManager: app_data, announce_packet_hash, ): - # get rssi, snr and signal quality if available - rssi = reticulum.get_packet_rssi(announce_packet_hash) - snr = reticulum.get_packet_snr(announce_packet_hash) - quality = reticulum.get_packet_q(announce_packet_hash) + if self.config: + limit = self._get_limit_for_aspect(aspect) + if limit is not None and limit >= 0: + dest_hex = ( + destination_hash.hex() + if isinstance(destination_hash, bytes) + else destination_hash + ) + existing = self.db.announces.get_announce_by_hash(dest_hex) + if not existing or existing.get("aspect") != aspect: + count = self.db.announces.get_announce_count_by_aspect(aspect) + if count >= limit: + return + rssi = snr = quality = None + if announce_packet_hash and reticulum: + rssi = reticulum.get_packet_rssi(announce_packet_hash) + snr = reticulum.get_packet_snr(announce_packet_hash) + quality = reticulum.get_packet_q(announce_packet_hash) # prepare data to insert or update data = { diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index ddfc377..cd36ebf 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -313,6 +313,23 @@ class ConfigManager: "#ef4444", ) + # announce limits (optional, None = no limit; protects against flood on public networks) + self.announce_limit_lxmf_delivery = self.IntConfig( + self, + "announce_limit_lxmf_delivery", + None, + ) + self.announce_limit_nomadnetwork_node = self.IntConfig( + self, + "announce_limit_nomadnetwork_node", + None, + ) + self.announce_limit_lxmf_propagation = self.IntConfig( + self, + "announce_limit_lxmf_propagation", + None, + ) + # blackhole integration config self.blackhole_integration_enabled = self.BoolConfig( self, diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py index c9172ef..dbcb097 100644 --- a/meshchatx/src/backend/database/announces.py +++ b/meshchatx/src/backend/database/announces.py @@ -54,6 +54,13 @@ class AnnounceDAO: (destination_hash,), ) + def get_announce_count_by_aspect(self, aspect): + row = self.provider.fetchone( + "SELECT COUNT(*) as count FROM announces WHERE aspect = ?", + (aspect,), + ) + return row["count"] if row else 0 + def delete_all_announces(self, aspect=None): if aspect: self.provider.execute( diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 26e98fd..93ac11f 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -156,7 +156,7 @@ class IdentityContext: self.config.docs_download_urls.set(self.app.docs_download_urls_override) self.message_handler = MessageHandler(self.database) - self.announce_manager = AnnounceManager(self.database) + self.announce_manager = AnnounceManager(self.database, self.config) self.archiver_manager = ArchiverManager(self.database) self.map_manager = MapManager(self.config, self.app.storage_dir) self.docs_manager = DocsManager(