Philipp Hörist pushed to branch master at gajim / gajim
Commits: 31421f74 by mjk at 2022-11-05T16:16:40+00:00 imprv: Styling: Validate link syntax deeper and *after* parenthesis correction
- - - - - d94d8272 by mjk at 2022-11-05T21:14:00+00:00 refactor: Always include the source string in the result of parse_uri
Improves debuggability, obviates formatting the errors.
- - - - - c6714b64 by mjk at 2022-11-05T21:18:52+00:00 fix: URI parsing: Properly unescape XMPP URIs and handle unknown query types
- - - - - b5b81605 by mjk at 2022-11-05T21:18:52+00:00 refactor: Clean up {parse,open}_uri
* Remove URIType.UNKNOWN * Remove special handling of JID-like strings. This almost never happens, and if it does, it's only due to some unsanitized input, e.g. server contact addresses blindly turned into hyperlinks with no validation (to be improved). * Misc.
- - - - - 0fda2adf by mjk at 2022-11-05T21:18:52+00:00 fix: URI parsing: Properly unescape mailto URIs
- - - - - f0feaba1 by mjk at 2022-11-05T21:18:52+00:00 fix: URI parsing: More robustness with geo URIs
* Don't treat altitude as part of longitude * Detect more invalid URIs
- - - - - 69af2ce6 by mjk at 2022-11-05T21:18:52+00:00 imprv: URI parsing: Detect invalid file URIs earlier
...including: in chat messages and file sending UI.
Also, don't generate wrong URIs like 'file://file:/path' when opening 'file:/path', and properly escape paths: 'file:///¿Qué%3F' <- '/¿Qué?'.
- - - - - 710cab12 by mjk at 2022-11-05T21:19:12+00:00 refactor: Minor refactoring and TODOs sprinkling in open_uri
- - - - - 7eaa8ae5 by mjk at 2022-11-05T21:20:28+00:00 imprv: VCardGrid: Linkify all URI-type fields iff they are valid URIs
- - - - - 225f6eca by mjk at 2022-11-05T21:20:28+00:00 cq: Remove unused imports
- - - - - c497eae2 by mjk at 2022-11-05T21:20:36+00:00 refactor: Factor open_uri_externally out of open_uri
- - - - - da620714 by mjk at 2022-11-05T21:21:44+00:00 change: Block opening file:/ links by default
Controlled by a new ACE variable.
Fixes #11276
- - - - - e93a928b by mjk at 2022-11-05T21:21:44+00:00 refactor: Use sys.platform in open_file(_uri)
- - - - -
13 changed files:
- gajim/common/const.py - gajim/common/helpers.py - gajim/common/regex.py - gajim/common/setting_values.py - gajim/common/structs.py - gajim/common/styling.py - gajim/gtk/avatar_selector.py - gajim/gtk/control.py - gajim/gtk/history_export.py - gajim/gtk/menus.py - gajim/gtk/start_chat.py - gajim/gtk/vcard_grid.py - test/no_gui/test_styling.py
Changes:
===================================== gajim/common/const.py ===================================== @@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Any +from typing import Optional from typing import Union from typing import NamedTuple
@@ -215,7 +216,7 @@ class Display(Enum):
class URIType(Enum): - UNKNOWN = 'unknown' + INVALID = 'invalid' XMPP = 'xmpp' MAIL = 'mail' GEO = 'geo' @@ -225,10 +226,18 @@ class URIType(Enum): TEL = 'tel'
-class URIAction(Enum): +# https://xmpp.org/registrar/querytypes.html +class XmppUriQuery(Enum): + NONE = '' MESSAGE = 'message' JOIN = 'join' - SUBSCRIBE = 'subscribe' + + @staticmethod + def from_str(s: str) -> Optional[XmppUriQuery]: + try: + return XmppUriQuery(s) + except ValueError: + return None
class MUCJoinedState(Enum):
===================================== gajim/common/helpers.py ===================================== @@ -94,14 +94,14 @@ from gajim.common.const import SHOW_STRING from gajim.common.const import SHOW_STRING_MNEMONIC from gajim.common.const import URIType -from gajim.common.const import URIAction +from gajim.common.const import XmppUriQuery from gajim.common.const import GIO_TLS_ERRORS from gajim.common.const import SHOW_LIST from gajim.common.const import CONSONANTS from gajim.common.const import VOWELS from gajim.common.regex import INVALID_XML_CHARS_REGEX -from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX from gajim.common.structs import URI +from gajim.common.text_helpers import escape_iri_path from gajim.common import types if TYPE_CHECKING: from gajim.common.modules.util import LogAdapter @@ -349,19 +349,6 @@ def build_command(executable: str, parameter: str) -> str: return command
-def get_file_path_from_dnd_dropped_uri(uri: str) -> str: - path = unquote(uri) # escape special chars - path = path.strip('\r\n\x00') # remove \r\n and NULL - # get the path to file - if re.match('^file:///[a-zA-Z]:/', path): # windows - path = path[8:] # 8 is len('file:///') - elif path.startswith('file://'): # nautilus, rox - path = path[7:] # 7 is len('file://') - elif path.startswith('file:'): # xffm - path = path[5:] # 5 is len('file:') - return path - - def sanitize_filename(filename: str) -> str: ''' Sanitize filename of elements not allowed on Windows @@ -936,74 +923,113 @@ def func_wrapper(self, *args, **kwargs): return func_wrapper
-def parse_uri_actions(uri: str) -> tuple[str, dict[str, str]]: - uri = uri[5:] - if '?' not in uri: - return 'message', {'jid': uri} +def parse_xmpp_uri_query(pct_iquerycomp: str) -> tuple[str, dict[str, str]]: + ''' + Parses 'mess%61ge;b%6Fdy=Hello%20%F0%9F%8C%90%EF%B8%8F' into + ('message', {'body': 'Hello 🌐️'}), empty string into ('', {}). + ''' + # Syntax and terminology from + # https://rfc-editor.org/rfc/rfc5122#section-2.2; the pct_ prefix means + # percent encoding not being undone yet.
- jid, action = uri.split('?', 1) - data = {'jid': jid} - if ';' in action: - action, keys = action.split(';', 1) - action_keys = keys.split(';') - for key in action_keys: - if key.startswith('subject='): - data['subject'] = unquote(key[8:]) + if not pct_iquerycomp: + return '', {}
- elif key.startswith('body='): - data['body'] = unquote(key[5:]) + pct_iquerytype, _, pct_ipairs = pct_iquerycomp.partition(';') + iquerytype = unquote(pct_iquerytype, errors='strict') + if not pct_ipairs: + return iquerytype, {}
- elif key.startswith('thread='): - data['thread'] = key[7:] - return action, data + pairs: dict[str, str] = {} + for pct_ipair in pct_ipairs.split(';'): + pct_ikey, _, pct_ival = pct_ipair.partition('=') + pairs[ + unquote(pct_ikey, errors='strict') + ] = unquote(pct_ival, errors='replace') + return iquerytype, pairs
def parse_uri(uri: str) -> URI: - if uri.startswith('xmpp:'): - action, data = parse_uri_actions(uri) + try: + urlparts = urlparse(uri) + except Exception as err: + return URI(URIType.INVALID, uri, data={'error': str(err)}) + + if not urlparts.scheme: + return URI(URIType.INVALID, uri, data={'error': 'Relative URI'}) + + scheme = urlparts.scheme # urlparse is expected to return it in lower case + + if scheme in ('https', 'http'): + if not urlparts.netloc: + err = f'No host or empty host in an {scheme} URI' + return URI(URIType.INVALID, uri, data={'error': err}) + return URI(URIType.WEB, uri, data=uri) + + if scheme == 'xmpp': + if not urlparts.path.startswith('/'): + pct_jid = urlparts.path + else: + pct_jid = urlparts.path[1:] + + data: dict[str, str] = {} try: + data['jid'] = unquote(pct_jid, errors='strict') validate_jid(data['jid']) - return URI(type=URIType.XMPP, - action=URIAction(action), - data=data) - except ValueError: - # Unknown action - return URI(type=URIType.UNKNOWN) - - if uri.startswith('mailto:'): - uri = uri[7:] - return URI(type=URIType.MAIL, data=uri) - - if uri.startswith('tel:'): - uri = uri[4:] - return URI(type=URIType.TEL, data=uri) - - if STH_AT_STH_DOT_STH_REGEX.match(uri): - return URI(type=URIType.AT, data=uri) - - if uri.startswith('geo:'): - location = uri[4:] - lat, _, lon = location.partition(',') + qtype, qparams = parse_xmpp_uri_query(urlparts.query) + except ValueError as err: + data['error'] = str(err) + return URI(URIType.INVALID, uri, data=data) + + return URI(URIType.XMPP, uri, + query_type=qtype, + query_params=qparams, + data=data) + + if scheme == 'mailto': + try: + data = unquote(urlparts.path, errors='strict') + validate_jid(data, 'bare') # meh, good enuf + except ValueError as err: + data = {'error': str(err)} + return URI(URIType.INVALID, uri, data=data) + return URI(URIType.MAIL, uri, data=data) + + if scheme == 'tel': + data = uri[4:] + return URI(URIType.TEL, uri, data=data) + + if scheme == 'geo': + # TODO: unify with .preview_helpers.split_geo_uri + # https://rfc-editor.org/rfc/rfc5870#section-3.3 + lat, _, lon_alt = urlparts.path.partition(',') + if not lat or not lon_alt: + return URI(URIType.INVALID, uri, data={ + 'error': 'No latitude or longitude'}) + lon, _, _alt = lon_alt.partition(',') if not lon: - return URI(type=URIType.UNKNOWN, data=uri) + return URI(URIType.INVALID, uri, data={'error': 'No longitude'})
+ # TODO: move redirection to open_uri, parsing needs to be unit-testable if Gio.AppInfo.get_default_for_uri_scheme('geo'): - return URI(type=URIType.GEO, data=uri) + return URI(URIType.GEO, uri, data=uri)
- uri = geo_provider_from_location(lat, lon) - return URI(type=URIType.GEO, data=uri) + data = geo_provider_from_location(lat, lon) + return URI(URIType.GEO, uri, data=data)
- if uri.startswith('file://'): - return URI(type=URIType.FILE, data=uri) - - try: - urlparts = urlparse(uri) - if not urlparts.scheme: - return URI(type=URIType.WEB, data=f'http://%7Buri%7D') - except Exception: - pass + if scheme == 'file': + # https://rfc-editor.org/rfc/rfc8089.html + data: dict[str, str] = {} + try: + data['netloc'] = urlparts.netloc + data['path'] = Gio.File.new_for_uri(uri).get_path() + assert data['path'] + except Exception as err: + data['error'] = str(err) + return URI(URIType.INVALID, uri, data=data) + return URI(URIType.FILE, uri, data=data)
- return URI(type=URIType.WEB, data=uri) + return URI(URIType.WEB, uri, data=uri)
@catch_exceptions @@ -1012,64 +1038,105 @@ def open_uri(uri: Union[URI, str], account: Optional[str] = None) -> None: uri = parse_uri(uri)
if uri.type == URIType.FILE: - open_file(uri.data) - - elif uri.type == URIType.TEL: - if sys.platform == 'win32': - webbrowser.open(f'tel:{uri.data}') + opt_name = 'allow_open_file_uris' + if app.settings.get(opt_name): + open_file_uri(uri.source) else: - Gio.AppInfo.launch_default_for_uri(f'tel:{uri.data}') + log.info('Blocked opening a file URI, see %s option', opt_name)
- elif uri.type == URIType.MAIL: - if sys.platform == 'win32': - webbrowser.open(f'mailto:{uri.data}') - else: - Gio.AppInfo.launch_default_for_uri(f'mailto:{uri.data}') + elif uri.type in (URIType.MAIL, URIType.TEL): + open_uri_externally(uri.source)
elif uri.type in (URIType.WEB, URIType.GEO): - if sys.platform == 'win32': - webbrowser.open(uri.data) - else: - Gio.AppInfo.launch_default_for_uri(uri.data) + open_uri_externally(uri.data)
- elif uri.type == URIType.AT: - app.window.start_chat_from_jid(account, uri.data) - - elif uri.type == URIType.XMPP: + elif uri.type in (URIType.XMPP, URIType.AT): if account is None: log.warning('Account must be specified to open XMPP uri') return
- if isinstance(uri.data, dict): + if uri.type == URIType.XMPP: jid = uri.data['jid'] - message = uri.data.get('body') else: - log.warning('Cant open URI: %s', uri) - return - - if uri.action == URIAction.JOIN: + jid = uri.data + + qtype, qparams = XmppUriQuery.from_str(uri.query_type), uri.query_params + if not qtype: + log.info('open_uri: can't "%s": ' + 'unsupported query type in %s', uri.query_type, uri) + # From <rfc5122#section-2.5>: + # > If the processing application does not understand [...] the + # > specified query type, it MUST ignore the query component and + # > treat the IRI/URI as consisting of, for example, + # > xmpp:example-node@example.com rather than + # > xmpp:example-node@example.com?query." + qtype, qparams = XmppUriQuery.NONE, {} + + if qtype == XmppUriQuery.JOIN: app.app.activate_action( 'groupchat-join', GLib.Variant('as', [account, jid])) - elif uri.action == URIAction.MESSAGE: - app.window.start_chat_from_jid(account, jid, message=message) else: - log.warning('Cant open URI: %s', uri) + message = qparams.get('body') + app.window.start_chat_from_jid(account, jid, message=message) + + elif uri.type == URIType.INVALID: + log.warning('open_uri: Invalid %s', uri) + # TODO: UI error instead
else: - log.warning('Cant open URI: %s', uri) + log.error('open_uri: No handler for %s', uri) + # TODO: this is a bug, so, `raise` maybe? + + +def open_uri_externally(uri: str) -> None: + if sys.platform == 'win32': + webbrowser.open(uri, new=2) + else: + try: + Gio.AppInfo.launch_default_for_uri(uri) + except GLib.Error as err: + log.info('open_uri_externally: ' + "Couldn't launch default for %s: %s", uri, err) + + +def open_file_uri(uri: str) -> None: + try: + if sys.platform != 'win32': + Gio.AppInfo.launch_default_for_uri(uri) + else: + os.startfile(uri) + except Exception as err: + log.info("Couldn't open file URI %s: %s", uri, err)
@catch_exceptions def open_file(path: Union[str, Path]) -> None: - if os.name == 'nt': + if sys.platform == 'win32': os.startfile(path) else: # Call str() to make it work with pathlib.Path path = str(path) - if not path.startswith('file://'): - path = 'file://' + path - Gio.AppInfo.launch_default_for_uri(path) + if path.startswith('/'): + open_file_uri('file://' + escape_iri_path(path)) + else: + open_file_uri(path) + + +def filesystem_path_from_uri(uri: str) -> Optional[Path]: + puri = parse_uri(uri) + if puri.type != URIType.FILE: + return None + + netloc = puri.data['netloc'] + if netloc and netloc.lower() != 'localhost' and sys.platform != 'win32': + return None + return Path(puri.data['path']) + + +def get_file_path_from_dnd_dropped_uri(text: str) -> Optional[Path]: + uri = text.strip('\r\n\x00') # remove \r\n and NULL + return filesystem_path_from_uri(uri)
def file_is_locked(path_to_file: str) -> bool:
===================================== gajim/common/regex.py ===================================== @@ -19,10 +19,6 @@ INVALID_XML_CHARS_REGEX = re.compile( '[\x00-\x08]|[\x0b-\x0c]|[\x0e-\x1f]|[\ud800-\udfff]|[\ufffe-\uffff]')
-# at least one character in 3 parts (before @, after @, after .) -STH_AT_STH_DOT_STH_REGEX = re.compile( - r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$') -
# Universal/Internationalized Resource Identifiers and their components, as # defined in [ABNF for IRI References and IRIs]
===================================== gajim/common/setting_values.py ===================================== @@ -36,6 +36,7 @@ class _ACCOUNT_DEFAULT:
BoolSettings = Literal[ + 'allow_open_file_uris', 'always_english_wikipedia', 'always_english_wiktionary', 'ascii_formatting', @@ -162,6 +163,7 @@ class _ACCOUNT_DEFAULT: AllSettingsT = Union[str, int, bool, list[str]]
APP_SETTINGS = { + 'allow_open_file_uris': False, 'always_english_wikipedia': False, 'always_english_wiktionary': True, 'ascii_formatting': True, @@ -624,6 +626,7 @@ class WorkspaceSettings(TypedDict):
ADVANCED_SETTINGS = { 'app': { + 'allow_open_file_uris': _('Allows opening file:/ URIs when clicked.'), 'always_english_wikipedia': '', 'always_english_wiktionary': '', 'ascii_formatting': _(
===================================== gajim/common/structs.py ===================================== @@ -42,7 +42,6 @@ from gajim.common.const import KindConstant from gajim.common.const import PresenceShowExt from gajim.common.const import URIType -from gajim.common.const import URIAction
_T = TypeVar('_T') @@ -50,7 +49,9 @@
class URI(NamedTuple): type: URIType - action: Optional[URIAction] = None + source: str + query_type: str = '' + query_params: dict[str, str] = {} data: Optional[Union[dict[str, str], str]] = None
===================================== gajim/common/styling.py ===================================== @@ -16,6 +16,7 @@
from typing import Union from typing import Match +from typing import Optional
import string import sys @@ -26,6 +27,9 @@ from gi.repository import GLib
from gajim.common import regex +from gajim.common.const import URIType +from gajim.common.helpers import parse_uri as analyze_uri +from gajim.common.helpers import validate_jid from gajim.common.text_helpers import jid_to_iri from gajim.gui.emoji_data import emoji_data
@@ -329,7 +333,7 @@ def _parse_uris(line: str, offset_bytes: int) -> list[BaseHyperlink]: uris: list[BaseHyperlink] = []
- def make(start: int, end: int, is_jid: bool) -> BaseHyperlink: + def make(start: int, end: int, is_jid: bool) -> Optional[BaseHyperlink]: if line[end - 1] == ',': # Trim one trailing comma end -= 1 @@ -337,6 +341,9 @@ def make(start: int, end: int, is_jid: bool) -> BaseHyperlink: # Trim one trailing closing parenthesis if the match is preceded # by an opening one somewhere on the line end -= 1 + if re.fullmatch('[^:]+:', line[start:end]): + # URIs that consist only of a scheme are thusly excluded + return None return _make_hyperlink(line, start, end - 1, @@ -346,14 +353,9 @@ def make(start: int, end: int, is_jid: bool) -> BaseHyperlink:
for match in URI_OR_JID_RX.finditer(line): start, end = match.span() - if match.group('jid') is not None: - uris.append(make(start, end, True)) - elif not re.fullmatch('[^:]+:', line[start:end]) and\ - (not re.fullmatch('https?', match.group('scheme'), re.IGNORECASE) or - match.group('host')): - # URIs that consist only of a scheme are thusly excluded. - # http(s) URIs without host or with empty host are excluded too. - uris.append(make(start, end, False)) + hyperlink = make(start, end, bool(match.group('jid'))) + if hyperlink: + uris.append(hyperlink)
return uris
@@ -422,7 +424,7 @@ def _make_hyperlink(line: str, end: int, offset: int, offset_bytes: int, - is_jid: bool) -> BaseHyperlink: + is_jid: bool) -> Optional[BaseHyperlink]:
text = line[start:end + 1]
@@ -435,13 +437,23 @@ def _make_hyperlink(line: str, uri = text if is_jid: cls_ = XMPPAddress + try: + validate_jid(text) + except ValueError: + return None uri = jid_to_iri(text) - elif re.match('xmpp:', text, re.IGNORECASE): - cls_ = XMPPAddress - elif re.match('mailto:', text, re.IGNORECASE): - cls_ = MailAddress + else: - cls_ = Hyperlink + uri = text + auri = analyze_uri(uri) + if auri.type == URIType.XMPP: + cls_ = XMPPAddress + elif auri.type == URIType.MAIL: + cls_ = MailAddress + elif auri.type == URIType.INVALID: + return None + else: + cls_ = Hyperlink
return cls_(start=start, start_byte=start_byte,
===================================== gajim/gtk/avatar_selector.py ===================================== @@ -19,7 +19,6 @@ from typing import cast from typing import Optional
-import os import logging from enum import IntEnum from enum import unique @@ -148,9 +147,9 @@ def _on_drag_data_received(self, if target_type == 80: uri_split = selection.get_uris() # Might be more than one path = get_file_path_from_dnd_dropped_uri(uri_split[0]) - if not os.path.isfile(path): + if not path or not path.is_file(): return - self.prepare_crop_area(path) + self.prepare_crop_area(str(path))
@staticmethod def _get_pixbuf_from_path(path: str) -> Optional[GdkPixbuf.Pixbuf]:
===================================== gajim/gtk/control.py ===================================== @@ -19,7 +19,6 @@ from typing import Union from typing import cast
-import os import logging import time
@@ -491,13 +490,18 @@ def drag_data_file_transfer(self, selection: Gtk.SelectionData) -> None: uri_splitted = selection.get_uris() for uri in uri_splitted: path = get_file_path_from_dnd_dropped_uri(uri) - if not os.path.isfile(path): # is it a file? + if not path: + self.add_info_message( + _('The following address was not understood and was ' + 'not uploaded: %s') % uri) + continue + if not path.is_file(): # is it a regular file? self.add_info_message( _('The following file could not be accessed and was ' 'not uploaded: %s') % path) continue
- app.interface.start_file_transfer(self.contact, path) + app.interface.start_file_transfer(self.contact, str(path))
def get_our_nick(self) -> str: if isinstance(self.contact, GroupchatParticipant):
===================================== gajim/gtk/history_export.py ===================================== @@ -21,13 +21,13 @@ import time from datetime import datetime from pathlib import Path -import sys
from gi.repository import Gtk
from gajim.common import app from gajim.common import configpaths from gajim.common.const import KindConstant +from gajim.common.helpers import filesystem_path_from_uri from gajim.common.helpers import make_path_from_jid from gajim.common.i18n import _ from gajim.common.storage.archive import MessageExportRow @@ -203,11 +203,9 @@ def _set_complete(self) -> None: def _on_file_set(self, button: Gtk.FileChooserButton) -> None: uri = button.get_uri() assert uri is not None - - if sys.platform == 'win32': - self._export_directory = uri.removeprefix('file:///') - else: - self._export_directory = uri.removeprefix('file://') + path = filesystem_path_from_uri(uri) + assert path is not None + self._export_directory = str(path)
def get_account_and_directory(self) -> tuple[str, str]: assert self._account is not None
===================================== gajim/gtk/menus.py ===================================== @@ -38,7 +38,7 @@ from gajim.common.i18n import _ from gajim.common.i18n import get_short_lang_code from gajim.common.const import URIType -from gajim.common.const import URIAction +from gajim.common.const import XmppUriQuery from gajim.common.structs import URI from gajim.common.structs import VariantMixin from gajim.common.modules.contacts import GroupchatContact @@ -269,7 +269,7 @@ def get_conv_action_context_menu(account: str, submenu.append(item)
item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link')) - value = GLib.Variant.new_strv([account, uri_text]) + value = GLib.Variant.new_strv([account, f'https://%7Buri_text%7D']) item.set_action_target_value(value) submenu.append(item)
@@ -281,7 +281,7 @@ def get_conv_action_context_menu(account: str,
def get_conv_uri_context_menu(account: str, uri: URI) -> Optional[Gtk.Menu]: if uri.type == URIType.XMPP: - if uri.action == URIAction.JOIN: + if XmppUriQuery.from_str(uri.query_type) == XmppUriQuery.JOIN: context_menu = [ ('copy-text', _('Copy XMPP Address')), ('groupchat-join', _('Join Groupchat')),
===================================== gajim/gtk/start_chat.py ===================================== @@ -40,6 +40,8 @@
from gajim.common import app from gajim.common.const import Direction +from gajim.common.const import URIType +from gajim.common.helpers import parse_uri from gajim.common.helpers import validate_jid from gajim.common.helpers import to_user_string from gajim.common.helpers import get_group_chat_nick @@ -549,10 +551,9 @@ def _on_search_changed(self, search_entry: Gtk.SearchEntry) -> None: return
search_text = search_entry.get_text() - if search_text.startswith('xmpp:'): - search_text = search_text.removeprefix('xmpp:') - search_text = search_text.removesuffix('?join') - search_entry.set_text(search_text) + uri = parse_uri(search_text) + if uri.type == URIType.XMPP: + search_entry.set_text(uri.data['jid']) return
if '@' in search_text:
===================================== gajim/gtk/vcard_grid.py ===================================== @@ -30,6 +30,7 @@ from gajim.common.helpers import open_uri from gajim.common.helpers import parse_uri from gajim.common.structs import URI +from gajim.common.text_helpers import escape_iri_path_segment from gajim.gui.util import gtk_month from gajim.gui.util import python_month
@@ -237,7 +238,7 @@ class ValueLabel(Gtk.Label): def __init__(self, prop, account): Gtk.Label.__init__(self) self._prop = prop - self._uri: Optional[str] = None + self._uri: Optional[URI] = None self._account = account self.set_selectable(True) self.set_xalign(0) @@ -252,24 +253,45 @@ def __init__(self, prop, account): self.set_value(prop.value)
def set_value(self, value: str) -> None: + # EMAIL https://rfc-editor.org/rfc/rfc6350#section-6.4.2 if self._prop.name == 'email': - self._uri = URI(type=URIType.MAIL, data=value) - self.set_markup(value) - - elif self._prop.name in ('impp', 'tel'): - self._uri = parse_uri(value) - self.set_markup(value) + uri = 'mailto:' + escape_iri_path_segment(value) + self.set_value_with_uri(value, uri) + + # All the properties that can be (single) URIs: + # SOURCE https://rfc-editor.org/rfc/rfc6350#section-6.1.3 + # PHOTO https://rfc-editor.org/rfc/rfc6350#section-6.2.4 + # TEL https://rfc-editor.org/rfc/rfc6350#section-6.4.1 + # IMPP https://rfc-editor.org/rfc/rfc6350#section-6.4.3 + # TZ https://rfc-editor.org/rfc/rfc6350#section-6.5.1 + # GEO https://rfc-editor.org/rfc/rfc6350#section-6.5.2 + # LOGO https://rfc-editor.org/rfc/rfc6350#section-6.6.3 + # MEMBER https://rfc-editor.org/rfc/rfc6350#section-6.6.5 + # RELATED https://rfc-editor.org/rfc/rfc6350#section-6.6.6 + # SOUND https://rfc-editor.org/rfc/rfc6350#section-6.7.5 + # UID https://rfc-editor.org/rfc/rfc6350#section-6.7.6 + # URL https://rfc-editor.org/rfc/rfc6350#section-6.7.8 + # KEY https://rfc-editor.org/rfc/rfc6350#section-6.8.1 + # FBURL https://rfc-editor.org/rfc/rfc6350#section-6.9.1 + # CALADRURI https://rfc-editor.org/rfc/rfc6350#section-6.9.2 + # CALURI https://rfc-editor.org/rfc/rfc6350#section-6.9.3 + elif self._prop.name in ('source', 'photo', 'tel', 'impp', 'tz', 'geo', + 'logo', 'member', 'related', 'sound', 'uid', + 'url', 'key', 'fburl', 'caladruri', 'caluri'): + self.set_value_with_uri(value, value)
else: self.set_text(value)
- def set_markup(self, text: str) -> None: - if not text: - self.set_text('') + def set_value_with_uri(self, value: str, uri: str) -> None: + puri = parse_uri(uri) + if puri.type == URIType.INVALID: + self.set_text(value) return + self._uri = puri super().set_markup('<a href="{}">{}</a>'.format( - GLib.markup_escape_text(text), - GLib.markup_escape_text(text))) + GLib.markup_escape_text(uri), + GLib.markup_escape_text(value)))
def _on_activate_link(self, _label: Gtk.Label, _value: str) -> int: open_uri(self._uri, self._account)
===================================== test/no_gui/test_styling.py ===================================== @@ -1,5 +1,9 @@ import unittest
+from gajim import gui +gui.init('gtk') + +from gajim.common import app # Avoids circular imports from common.helpers import gajim.common.styling as styling from gajim.common.styling import PlainBlock from gajim.common.styling import PreBlock @@ -287,7 +291,7 @@ 'a:b', 'a-:b', 'a.:b', - 'xmpp:conference.gajim.org' + 'xmpp:conference.gajim.org', 'xmpp:asd@at', 'xmpp:asd@asd.at', 'xmpp:asd-asd@asd.asdasd.at.', @@ -297,6 +301,13 @@ 'xmpp:%23room%25irc.example@biboumi.xmpp.example', 'xmpp:+15551234567@cheogram.com', 'xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message', + 'geo:1,2', + 'geo:1,2,3', + 'file:/foo/bar/baz', # xffm + 'file:///foo/bar/baz', # nautilus, rox + 'file:///x:/foo/bar/baz', # windows + 'file://localhost/foo/bar/baz', + 'file://nonlocalhost/foo/bar/baz',
# These seem to be from https://mathiasbynens.be/demo/url-regex 'http://foo.com/blah_blah', @@ -401,6 +412,21 @@ 'http://##', 'http://##/', 'http:///a', + + 'geo:1,', + 'geo:,2', + #'geo:1,2,', FIXME: wrongly parsed as valid + 'geo:1,,3', + 'geo:,2,3', + 'geo:1,,', + 'geo:,2,', + 'geo:,,3', + 'geo:,,', + + 'file:', + 'file:a', + 'file:a/', + 'file:a/b', ]
@@ -498,9 +524,6 @@ def test_uris(self):
def test_invalid_uris(self): for foo in NONURIS + UNACCEPTABLE_URIS: - if foo == 'scheme:' or\ - foo == 'http://': - continue # these currently fial (FIXME) text = self.wrap(foo) hlinks = process_uris(text) if len(hlinks) == 0: @@ -518,8 +541,6 @@ def test_jids(self):
def test_nonjids(self): for foo in NONJIDS: - if foo == 'juliet@': - continue # this currently fials (FIXME) text = self.wrap(foo) hlinks = process_uris(text) if len(hlinks) == 0:
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/c5f6cc123276cb01b2be9cf6458a536b...