diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 336ae75..14844d3 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -5,6 +5,7 @@ import shutil import RNS.vendor.umsgpack as msgpack import nomadnet from nomadnet.Directory import DirectoryEntry +from LXMF import display_name_from_app_data class Conversation: cached_conversations = {} @@ -16,6 +17,9 @@ class Conversation: def received_announce(destination_hash, announced_identity, app_data): app = nomadnet.NomadNetworkApp.get_shared_instance() + if not display_name_from_app_data(app_data): + RNS.log("Ignored invalid lxmf.delivery announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + if not destination_hash in app.ignored_list: destination_hash_text = RNS.hexrep(destination_hash, delimit=False) # Check if the announced destination is in @@ -32,8 +36,7 @@ class Conversation: # for nomadnets storage and other handling functions. dn = LXMF.display_name_from_app_data(app_data) app_data = b"" - if dn != None: - app_data = dn.encode("utf-8") + if dn != None: app_data = dn.encode("utf-8") # Add the announce to the directory announce # stream logger @@ -670,9 +673,16 @@ class ConversationMessage: if os.path.isfile(manifest_path): try: with open(manifest_path, "rb") as f: - return msgpack.unpackb(f.read(), raw=False) - except Exception: - pass + manifest = msgpack.unpackb(f.read(), raw=False) + for f in manifest["files"]: + f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8") + + return manifest + + except Exception as e: + RNS.log(f"Error loading attachment manifest: {e}") + return None + return None @staticmethod @@ -702,13 +712,17 @@ class ConversationMessage: file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) if file_attachments: for idx, att in enumerate(file_attachments): - if isinstance(att, list) and len(att) >= 2: - filename = str(att[0]) - data = att[1] if isinstance(att[1], bytes) else b"" - stored_name = "file_"+str(idx) - with open(os.path.join(att_dir, stored_name), "wb") as f: - f.write(data) - manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + try: + if isinstance(att, list) and len(att) >= 2: + filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8") + data = att[1] if isinstance(att[1], bytes) else b"" + stored_name = "file_"+str(idx) + with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + except Exception as e: + RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) + continue if LXMF.FIELD_IMAGE in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 442983b..a3f86d5 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -8,6 +8,7 @@ import RNS.vendor.umsgpack as msgpack from LXMF import pn_announce_data_is_valid from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name class PNAnnounceHandler: def __init__(self, owner): @@ -156,11 +157,9 @@ class Directory: try: remove_announces = [] for announce in self._peer_announces: - if announce[1] == source_hash: - remove_announces.append(announce) + if announce[1] == source_hash: remove_announces.append(announce) - for a in remove_announces: - self._peer_announces.remove(a) + for a in remove_announces: self._peer_announces.remove(a) except Exception as e: RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) @@ -272,26 +271,27 @@ class Directory: else: return None - def simplest_display_str(self, source_hash): + def simplest_display_str(self, source_hash, san=True): + def s(name): + if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name) + else: return strip_modifiers(name) + trust_level = self.trust_level(source_hash) if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: if source_hash in self.directory_entries: - dn = self.directory_entries[source_hash].display_name - if dn == None: - return RNS.prettyhexrep(source_hash) - else: - return strip_modifiers(dn)+" <"+RNS.hexrep(source_hash, delimit=False)+">" - else: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + else: if source_hash in self.directory_entries: - dn = self.directory_entries[source_hash].display_name - if dn == None: - return RNS.prettyhexrep(source_hash) - else: - return strip_modifiers(dn) - else: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" def alleged_display_str(self, source_hash): if source_hash in self.directory_entries: diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 13f3371..62d2086 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -838,6 +838,11 @@ class NomadNetworkApp: else: self.config["textui"]["hide_guide"] = self.config["textui"].as_bool("hide_guide") + if not "sanitize_names" in self.config["textui"]: + self.config["textui"]["sanitize_names"] = True + else: + self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names") + if not "animation_interval" in self.config["textui"]: self.config["textui"]["animation_interval"] = 1 else: @@ -1178,6 +1183,11 @@ editor = nano # show up in the menu, you can disable it. hide_guide = no +# You can select whether or not to perform +# text sanitization on names received in +# announces. +sanitize_names = yes + [node] # Whether to enable node hosting diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index a1356f3..48bd74f 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -28,6 +28,7 @@ THEMES = { ("error_text", "dark red", "default", "default", "dark red", "default"), ("warning_text", "yellow", "default", "default", "#ba4", "default"), ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), + ("browser_inactive", "dark gray", "default", "default", "#444", "default"), ("buttons", "light green,bold", "default", "default", "#00a533", "default"), ("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"), ("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"), diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index f4785bc..4c0fc57 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -13,6 +13,7 @@ from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -288,6 +289,10 @@ class Browser: def handle_lxmf_link(self, link_target): try: + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + if not type(link_target) is str: raise ValueError("Invalid data type for LXMF link") @@ -307,7 +312,7 @@ class Browser: display_name = None if display_name_data != None: - display_name = LXMF.display_name_from_app_data(display_name_data) + display_name = san(LXMF.display_name_from_app_data(display_name_data)) if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) @@ -343,7 +348,7 @@ class Browser: self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) self.frame.delegate = self self.linebox = urwid.LineBox(self.frame, title="Remote Node") - self.display_widget = urwid.AttrMap(self.linebox, "inactive_text") + self.display_widget = urwid.AttrMap(self.linebox, "browser_inactive") def make_status_widget(self): if self.response_progress > 0: @@ -389,7 +394,7 @@ class Browser: def update_display(self): if self.status == Browser.DISCONECTED: - self.display_widget.set_attr_map({None: "inactive_text"}) + self.display_widget.set_attr_map({None: "browser_inactive"}) self.page_pile = None self.page_partials = {} self.browser_body = urwid.Filler( @@ -1073,7 +1078,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None @@ -1147,7 +1152,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None @@ -1306,7 +1311,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index e68f3c2..2af34f1 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -11,6 +11,9 @@ from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry from nomadnet.Conversation import ConversationMessage +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + def relative_time(timestamp): now = time.time() @@ -1083,6 +1086,10 @@ class ConversationWidget(urwid.WidgetWrap): super().__init__(self.display_widget) def _update_peer_info(self): + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + g = self.app.ui.glyphs source_hash_bytes = bytes.fromhex(self.source_hash) @@ -1093,7 +1100,7 @@ class ConversationWidget(urwid.WidgetWrap): if display_name is None: if app_data: - display_name = LXMF.display_name_from_app_data(app_data) + display_name = san(LXMF.display_name_from_app_data(app_data)) if display_name is None: display_name = RNS.prettyhexrep(source_hash_bytes) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 03becdd..ce6bcc5 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -803,6 +803,12 @@ The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphi Determines whether the program should react to mouse/touch input. Must be a boolean value. < +>>> +`!sanitize_names = yes`! +>>>> +This option allows sanitizing announced names before they are displayed in the user interface. +< + >>> `!hide_guide = no`! >>>> diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index fcf2a4e..aead47d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -7,6 +7,7 @@ from datetime import datetime from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name from .Browser import Browser @@ -275,16 +276,19 @@ class AnnounceStreamEntry(urwid.WidgetWrap): ts_string = dt.strftime(date_only_format) trust_level = self.app.directory.trust_level(source_hash) + + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) if show_destination: display_str = RNS.hexrep(source_hash, delimit=False) else: - try: - display_str = strip_modifiers(announce[2].decode("utf-8")) - if len(display_str) > 32: - display_str = display_str[:32] + "..." - except: - display_str = self.app.directory.simplest_display_str(source_hash) + try: display_str = san(announce[2].decode("utf-8")) + except: display_str = self.app.directory.simplest_display_str(source_hash) + + if not display_str: display_str = RNS.prettyhexrep(source_hash) + if len(display_str) > 34: display_str = display_str[:34] + "…" if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] @@ -488,16 +492,15 @@ class AnnounceStream(urwid.WidgetWrap): if announce_type == "node" or announce_type == True: node_count += 1 - if self.current_tab == "nodes": - new_entries.append(e) + if self.current_tab == "nodes": new_entries.append(e) + elif announce_type == "peer" or announce_type == False: peer_count += 1 - if self.current_tab == "peers": - new_entries.append(e) + if self.current_tab == "peers": new_entries.append(e) + elif announce_type == "pn": pn_count += 1 - if self.current_tab == "pn": - new_entries.append(e) + if self.current_tab == "pn": new_entries.append(e) for e in new_entries: nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination) @@ -980,7 +983,7 @@ class NodeEntry(urwid.WidgetWrap): g = self.app.ui.glyphs trust_level = self.app.directory.trust_level(source_hash) - display_str = self.app.directory.simplest_display_str(source_hash) + display_str = self.app.directory.simplest_display_str(source_hash, san=False) if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] diff --git a/nomadnet/util.py b/nomadnet/util.py index 0d8c79f..9f2562a 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -3,6 +3,59 @@ import unicodedata invalid_rendering = ["🕵️", "☝"] +# Unicode blocks to strip (symbols, dingbats, emoji ranges) +STRIP_BLOCKS_RE = re.compile( + '[' + '\U0001F600-\U0001F64F' # Emoticons + '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs + '\U0001F680-\U0001F6FF' # Transport & Map Symbols + '\U0001F700-\U0001F77F' # Alchemical Symbols + '\U0001F780-\U0001F7FF' # Geometric Shapes Extended + '\U0001F800-\U0001F8FF' # Supplemental Arrows-C + '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs + '\U0001FA00-\U0001FA6F' # Chess Symbols + '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A + '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators) + '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.) + '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.) + '\uFE00-\uFE0F' # Variation Selectors + '\U000E0100-\U000E01EF' # Variation Selectors Supplement + '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones) + ']+', + flags=re.UNICODE +) + +# Control characters and zero-width characters to strip +STRIP_CONTROL_RE = re.compile( + '[' + '\x00-\x08' # C0 controls (NUL-BS) + '\x0B\x0C' # VT, FF + '\x0E-\x1F' # C0 controls (SO-US) + '\x7F-\x9F' # DEL and C1 controls + '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc. + '\u202A-\u202E' # Bidi embedding controls + '\u2060-\u206F' # Format chars (word joiner, etc.) + '\uFEFF' # BOM / Zero Width NBSP + '\uFFF0-\uFFF8' # Specials + ']+', + flags=re.UNICODE +) + +# Surrogates and private use areas +# Shouldn't appear in valid UTF-8, but strip just in case +STRIP_PRIVATE_RE = re.compile( + '[' + '\uD800-\uDFFF' # Surrogates + '\uE000-\uF8FF' # Private Use Area + '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety) + '\uFE10-\uFE1F' # Vertical Forms + '\uFE20-\uFE2F' # Combining Half Marks + '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A + '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B + ']+', + flags=re.UNICODE +) + def strip_modifiers(text): def process_characters(text): result = [] @@ -14,14 +67,18 @@ def strip_modifiers(text): if category.startswith(('L', 'N', 'P', 'S')): result.append(char) i += 1 + elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c': i += 1 + else: result.append(char) i += 1 return ''.join(result) + if text == None: return None + for char in invalid_rendering: text = text.replace(char, " ") @@ -32,4 +89,55 @@ def strip_modifiers(text): stripped = re.sub(r'[\u200D\u200C]', '', stripped) stripped = re.sub(r'\r\n?', '\n', stripped) - return stripped + return stripped.strip().replace("\x00", "") + +def sanitize_name(name): + if name is None: return None + + # Convert to string and normalize to NFKC + # NFKC: Compatibility decomposition followed by canonical composition + # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms + name = str(name) + name = unicodedata.normalize('NFKC', name) + + # Build result using category-based filtering + result = [] + for char in name: + cat = unicodedata.category(char) + cat_prefix = cat[0] if cat else 'C' + + # Allow letters (L*), numbers (N*), and punctuation (P*) + if cat_prefix in ('L', 'N', 'P'): result.append(char) + + # Allow space separator, normalize to regular space + elif cat == 'Zs': result.append(' ') + + # Convert line/paragraph separators to space + elif cat in ('Zl', 'Zp'): result.append(' ') + + # Allow spacing combining marks (Mc) for Indic, Hebrew, etc. + elif cat == 'Mc': result.append(char) + + # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː + elif cat == 'Lm': result.append(char) + + # Strip everything else: + # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo) + # - Me (Enclosing Mark): enclosing combining marks + # - C* (Controls, Format, Surrogates, Private Use, Unassigned) + # - S* (Symbols: currency, math, modifiers, other) + + name = ''.join(result) + + # Additional block-based stripping for symbols that categories missed + name = STRIP_BLOCKS_RE.sub('', name) + name = STRIP_CONTROL_RE.sub('', name) + name = STRIP_PRIVATE_RE.sub('', name) + + # Collapse multiple whitespace characters + name = re.sub(r'\s+', ' ', name) + + # Strip leading/trailing whitespace + name = name.strip() + + return name diff --git a/setup.py b/setup.py index 97b527d..593fe8c 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.3", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], - python_requires=">=3.7", + install_requires=["rns>=1.2.3", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + python_requires=">=3.8", )