Philipp Hörist pushed to branch master at gajim / gajim

Commits:

13 changed files:

Changes:

  • gajim/common/const.py
    ... ... @@ -15,6 +15,7 @@
    15 15
     from __future__ import annotations
    
    16 16
     
    
    17 17
     from typing import Any
    
    18
    +from typing import Optional
    
    18 19
     from typing import Union
    
    19 20
     from typing import NamedTuple
    
    20 21
     
    
    ... ... @@ -215,7 +216,7 @@ class Display(Enum):
    215 216
     
    
    216 217
     
    
    217 218
     class URIType(Enum):
    
    218
    -    UNKNOWN = 'unknown'
    
    219
    +    INVALID = 'invalid'
    
    219 220
         XMPP = 'xmpp'
    
    220 221
         MAIL = 'mail'
    
    221 222
         GEO = 'geo'
    
    ... ... @@ -225,10 +226,18 @@ class URIType(Enum):
    225 226
         TEL = 'tel'
    
    226 227
     
    
    227 228
     
    
    228
    -class URIAction(Enum):
    
    229
    +# https://xmpp.org/registrar/querytypes.html
    
    230
    +class XmppUriQuery(Enum):
    
    231
    +    NONE = ''
    
    229 232
         MESSAGE = 'message'
    
    230 233
         JOIN = 'join'
    
    231
    -    SUBSCRIBE = 'subscribe'
    
    234
    +
    
    235
    +    @staticmethod
    
    236
    +    def from_str(s: str) -> Optional[XmppUriQuery]:
    
    237
    +        try:
    
    238
    +            return XmppUriQuery(s)
    
    239
    +        except ValueError:
    
    240
    +            return None
    
    232 241
     
    
    233 242
     
    
    234 243
     class MUCJoinedState(Enum):
    

  • gajim/common/helpers.py
    ... ... @@ -94,14 +94,14 @@
    94 94
     from gajim.common.const import SHOW_STRING
    
    95 95
     from gajim.common.const import SHOW_STRING_MNEMONIC
    
    96 96
     from gajim.common.const import URIType
    
    97
    -from gajim.common.const import URIAction
    
    97
    +from gajim.common.const import XmppUriQuery
    
    98 98
     from gajim.common.const import GIO_TLS_ERRORS
    
    99 99
     from gajim.common.const import SHOW_LIST
    
    100 100
     from gajim.common.const import CONSONANTS
    
    101 101
     from gajim.common.const import VOWELS
    
    102 102
     from gajim.common.regex import INVALID_XML_CHARS_REGEX
    
    103
    -from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
    
    104 103
     from gajim.common.structs import URI
    
    104
    +from gajim.common.text_helpers import escape_iri_path
    
    105 105
     from gajim.common import types
    
    106 106
     if TYPE_CHECKING:
    
    107 107
         from gajim.common.modules.util import LogAdapter
    
    ... ... @@ -349,19 +349,6 @@ def build_command(executable: str, parameter: str) -> str:
    349 349
         return command
    
    350 350
     
    
    351 351
     
    
    352
    -def get_file_path_from_dnd_dropped_uri(uri: str) -> str:
    
    353
    -    path = unquote(uri)  # escape special chars
    
    354
    -    path = path.strip('\r\n\x00')  # remove \r\n and NULL
    
    355
    -    # get the path to file
    
    356
    -    if re.match('^file:///[a-zA-Z]:/', path):  # windows
    
    357
    -        path = path[8:]  # 8 is len('file:///')
    
    358
    -    elif path.startswith('file://'):  # nautilus, rox
    
    359
    -        path = path[7:]  # 7 is len('file://')
    
    360
    -    elif path.startswith('file:'):  # xffm
    
    361
    -        path = path[5:]  # 5 is len('file:')
    
    362
    -    return path
    
    363
    -
    
    364
    -
    
    365 352
     def sanitize_filename(filename: str) -> str:
    
    366 353
         '''
    
    367 354
         Sanitize filename of elements not allowed on Windows
    
    ... ... @@ -936,74 +923,113 @@ def func_wrapper(self, *args, **kwargs):
    936 923
         return func_wrapper
    
    937 924
     
    
    938 925
     
    
    939
    -def parse_uri_actions(uri: str) -> tuple[str, dict[str, str]]:
    
    940
    -    uri = uri[5:]
    
    941
    -    if '?' not in uri:
    
    942
    -        return 'message', {'jid': uri}
    
    926
    +def parse_xmpp_uri_query(pct_iquerycomp: str) -> tuple[str, dict[str, str]]:
    
    927
    +    '''
    
    928
    +    Parses 'mess%61ge;b%6Fdy=Hello%20%F0%9F%8C%90%EF%B8%8F' into
    
    929
    +    ('message', {'body': 'Hello 🌐️'}), empty string into ('', {}).
    
    930
    +    '''
    
    931
    +    # Syntax and terminology from
    
    932
    +    # <https://rfc-editor.org/rfc/rfc5122#section-2.2>; the pct_ prefix means
    
    933
    +    # percent encoding not being undone yet.
    
    943 934
     
    
    944
    -    jid, action = uri.split('?', 1)
    
    945
    -    data = {'jid': jid}
    
    946
    -    if ';' in action:
    
    947
    -        action, keys = action.split(';', 1)
    
    948
    -        action_keys = keys.split(';')
    
    949
    -        for key in action_keys:
    
    950
    -            if key.startswith('subject='):
    
    951
    -                data['subject'] = unquote(key[8:])
    
    935
    +    if not pct_iquerycomp:
    
    936
    +        return '', {}
    
    952 937
     
    
    953
    -            elif key.startswith('body='):
    
    954
    -                data['body'] = unquote(key[5:])
    
    938
    +    pct_iquerytype, _, pct_ipairs = pct_iquerycomp.partition(';')
    
    939
    +    iquerytype = unquote(pct_iquerytype, errors='strict')
    
    940
    +    if not pct_ipairs:
    
    941
    +        return iquerytype, {}
    
    955 942
     
    
    956
    -            elif key.startswith('thread='):
    
    957
    -                data['thread'] = key[7:]
    
    958
    -    return action, data
    
    943
    +    pairs: dict[str, str] = {}
    
    944
    +    for pct_ipair in pct_ipairs.split(';'):
    
    945
    +        pct_ikey, _, pct_ival = pct_ipair.partition('=')
    
    946
    +        pairs[
    
    947
    +            unquote(pct_ikey, errors='strict')
    
    948
    +        ] = unquote(pct_ival, errors='replace')
    
    949
    +    return iquerytype, pairs
    
    959 950
     
    
    960 951
     
    
    961 952
     def parse_uri(uri: str) -> URI:
    
    962
    -    if uri.startswith('xmpp:'):
    
    963
    -        action, data = parse_uri_actions(uri)
    
    953
    +    try:
    
    954
    +        urlparts = urlparse(uri)
    
    955
    +    except Exception as err:
    
    956
    +        return URI(URIType.INVALID, uri, data={'error': str(err)})
    
    957
    +
    
    958
    +    if not urlparts.scheme:
    
    959
    +        return URI(URIType.INVALID, uri, data={'error': 'Relative URI'})
    
    960
    +
    
    961
    +    scheme = urlparts.scheme  # urlparse is expected to return it in lower case
    
    962
    +
    
    963
    +    if scheme in ('https', 'http'):
    
    964
    +        if not urlparts.netloc:
    
    965
    +            err = f'No host or empty host in an {scheme} URI'
    
    966
    +            return URI(URIType.INVALID, uri, data={'error': err})
    
    967
    +        return URI(URIType.WEB, uri, data=uri)
    
    968
    +
    
    969
    +    if scheme == 'xmpp':
    
    970
    +        if not urlparts.path.startswith('/'):
    
    971
    +            pct_jid = urlparts.path
    
    972
    +        else:
    
    973
    +            pct_jid = urlparts.path[1:]
    
    974
    +
    
    975
    +        data: dict[str, str] = {}
    
    964 976
             try:
    
    977
    +            data['jid'] = unquote(pct_jid, errors='strict')
    
    965 978
                 validate_jid(data['jid'])
    
    966
    -            return URI(type=URIType.XMPP,
    
    967
    -                       action=URIAction(action),
    
    968
    -                       data=data)
    
    969
    -        except ValueError:
    
    970
    -            # Unknown action
    
    971
    -            return URI(type=URIType.UNKNOWN)
    
    972
    -
    
    973
    -    if uri.startswith('mailto:'):
    
    974
    -        uri = uri[7:]
    
    975
    -        return URI(type=URIType.MAIL, data=uri)
    
    976
    -
    
    977
    -    if uri.startswith('tel:'):
    
    978
    -        uri = uri[4:]
    
    979
    -        return URI(type=URIType.TEL, data=uri)
    
    980
    -
    
    981
    -    if STH_AT_STH_DOT_STH_REGEX.match(uri):
    
    982
    -        return URI(type=URIType.AT, data=uri)
    
    983
    -
    
    984
    -    if uri.startswith('geo:'):
    
    985
    -        location = uri[4:]
    
    986
    -        lat, _, lon = location.partition(',')
    
    979
    +            qtype, qparams = parse_xmpp_uri_query(urlparts.query)
    
    980
    +        except ValueError as err:
    
    981
    +            data['error'] = str(err)
    
    982
    +            return URI(URIType.INVALID, uri, data=data)
    
    983
    +
    
    984
    +        return URI(URIType.XMPP, uri,
    
    985
    +                   query_type=qtype,
    
    986
    +                   query_params=qparams,
    
    987
    +                   data=data)
    
    988
    +
    
    989
    +    if scheme == 'mailto':
    
    990
    +        try:
    
    991
    +            data = unquote(urlparts.path, errors='strict')
    
    992
    +            validate_jid(data, 'bare')  # meh, good enuf
    
    993
    +        except ValueError as err:
    
    994
    +            data = {'error': str(err)}
    
    995
    +            return URI(URIType.INVALID, uri, data=data)
    
    996
    +        return URI(URIType.MAIL, uri, data=data)
    
    997
    +
    
    998
    +    if scheme == 'tel':
    
    999
    +        data = uri[4:]
    
    1000
    +        return URI(URIType.TEL, uri, data=data)
    
    1001
    +
    
    1002
    +    if scheme == 'geo':
    
    1003
    +        # TODO: unify with .preview_helpers.split_geo_uri
    
    1004
    +        # https://rfc-editor.org/rfc/rfc5870#section-3.3
    
    1005
    +        lat, _, lon_alt = urlparts.path.partition(',')
    
    1006
    +        if not lat or not lon_alt:
    
    1007
    +            return URI(URIType.INVALID, uri, data={
    
    1008
    +                       'error': 'No latitude or longitude'})
    
    1009
    +        lon, _, _alt = lon_alt.partition(',')
    
    987 1010
             if not lon:
    
    988
    -            return URI(type=URIType.UNKNOWN, data=uri)
    
    1011
    +            return URI(URIType.INVALID, uri, data={'error': 'No longitude'})
    
    989 1012
     
    
    1013
    +        # TODO: move redirection to open_uri, parsing needs to be unit-testable
    
    990 1014
             if Gio.AppInfo.get_default_for_uri_scheme('geo'):
    
    991
    -            return URI(type=URIType.GEO, data=uri)
    
    1015
    +            return URI(URIType.GEO, uri, data=uri)
    
    992 1016
     
    
    993
    -        uri = geo_provider_from_location(lat, lon)
    
    994
    -        return URI(type=URIType.GEO, data=uri)
    
    1017
    +        data = geo_provider_from_location(lat, lon)
    
    1018
    +        return URI(URIType.GEO, uri, data=data)
    
    995 1019
     
    
    996
    -    if uri.startswith('file://'):
    
    997
    -        return URI(type=URIType.FILE, data=uri)
    
    998
    -
    
    999
    -    try:
    
    1000
    -        urlparts = urlparse(uri)
    
    1001
    -        if not urlparts.scheme:
    
    1002
    -            return URI(type=URIType.WEB, data=f'http://{uri}')
    
    1003
    -    except Exception:
    
    1004
    -        pass
    
    1020
    +    if scheme == 'file':
    
    1021
    +        # https://rfc-editor.org/rfc/rfc8089.html
    
    1022
    +        data: dict[str, str] = {}
    
    1023
    +        try:
    
    1024
    +            data['netloc'] = urlparts.netloc
    
    1025
    +            data['path'] = Gio.File.new_for_uri(uri).get_path()
    
    1026
    +            assert data['path']
    
    1027
    +        except Exception as err:
    
    1028
    +            data['error'] = str(err)
    
    1029
    +            return URI(URIType.INVALID, uri, data=data)
    
    1030
    +        return URI(URIType.FILE, uri, data=data)
    
    1005 1031
     
    
    1006
    -    return URI(type=URIType.WEB, data=uri)
    
    1032
    +    return URI(URIType.WEB, uri, data=uri)
    
    1007 1033
     
    
    1008 1034
     
    
    1009 1035
     @catch_exceptions
    
    ... ... @@ -1012,64 +1038,105 @@ def open_uri(uri: Union[URI, str], account: Optional[str] = None) -> None:
    1012 1038
             uri = parse_uri(uri)
    
    1013 1039
     
    
    1014 1040
         if uri.type == URIType.FILE:
    
    1015
    -        open_file(uri.data)
    
    1016
    -
    
    1017
    -    elif uri.type == URIType.TEL:
    
    1018
    -        if sys.platform == 'win32':
    
    1019
    -            webbrowser.open(f'tel:{uri.data}')
    
    1041
    +        opt_name = 'allow_open_file_uris'
    
    1042
    +        if app.settings.get(opt_name):
    
    1043
    +            open_file_uri(uri.source)
    
    1020 1044
             else:
    
    1021
    -            Gio.AppInfo.launch_default_for_uri(f'tel:{uri.data}')
    
    1045
    +            log.info('Blocked opening a file URI, see %s option', opt_name)
    
    1022 1046
     
    
    1023
    -    elif uri.type == URIType.MAIL:
    
    1024
    -        if sys.platform == 'win32':
    
    1025
    -            webbrowser.open(f'mailto:{uri.data}')
    
    1026
    -        else:
    
    1027
    -            Gio.AppInfo.launch_default_for_uri(f'mailto:{uri.data}')
    
    1047
    +    elif uri.type in (URIType.MAIL, URIType.TEL):
    
    1048
    +        open_uri_externally(uri.source)
    
    1028 1049
     
    
    1029 1050
         elif uri.type in (URIType.WEB, URIType.GEO):
    
    1030
    -        if sys.platform == 'win32':
    
    1031
    -            webbrowser.open(uri.data)
    
    1032
    -        else:
    
    1033
    -            Gio.AppInfo.launch_default_for_uri(uri.data)
    
    1051
    +        open_uri_externally(uri.data)
    
    1034 1052
     
    
    1035
    -    elif uri.type == URIType.AT:
    
    1036
    -        app.window.start_chat_from_jid(account, uri.data)
    
    1037
    -
    
    1038
    -    elif uri.type == URIType.XMPP:
    
    1053
    +    elif uri.type in (URIType.XMPP, URIType.AT):
    
    1039 1054
             if account is None:
    
    1040 1055
                 log.warning('Account must be specified to open XMPP uri')
    
    1041 1056
                 return
    
    1042 1057
     
    
    1043
    -        if isinstance(uri.data, dict):
    
    1058
    +        if uri.type == URIType.XMPP:
    
    1044 1059
                 jid = uri.data['jid']
    
    1045
    -            message = uri.data.get('body')
    
    1046 1060
             else:
    
    1047
    -            log.warning('Cant open URI: %s', uri)
    
    1048
    -            return
    
    1049
    -
    
    1050
    -        if uri.action == URIAction.JOIN:
    
    1061
    +            jid = uri.data
    
    1062
    +
    
    1063
    +        qtype, qparams = XmppUriQuery.from_str(uri.query_type), uri.query_params
    
    1064
    +        if not qtype:
    
    1065
    +            log.info('open_uri: can\'t "%s": '
    
    1066
    +                     'unsupported query type in %s', uri.query_type, uri)
    
    1067
    +            # From <rfc5122#section-2.5>:
    
    1068
    +            # > If the processing application does not understand [...] the
    
    1069
    +            # > specified query type, it MUST ignore the query component and
    
    1070
    +            # > treat the IRI/URI as consisting of, for example,
    
    1071
    +            # > <xmpp:example-node@example.com> rather than
    
    1072
    +            # > <xmpp:example-node@example.com?query>."
    
    1073
    +            qtype, qparams = XmppUriQuery.NONE, {}
    
    1074
    +
    
    1075
    +        if qtype == XmppUriQuery.JOIN:
    
    1051 1076
                 app.app.activate_action(
    
    1052 1077
                     'groupchat-join',
    
    1053 1078
                     GLib.Variant('as', [account, jid]))
    
    1054
    -        elif uri.action == URIAction.MESSAGE:
    
    1055
    -            app.window.start_chat_from_jid(account, jid, message=message)
    
    1056 1079
             else:
    
    1057
    -            log.warning('Cant open URI: %s', uri)
    
    1080
    +            message = qparams.get('body')
    
    1081
    +            app.window.start_chat_from_jid(account, jid, message=message)
    
    1082
    +
    
    1083
    +    elif uri.type == URIType.INVALID:
    
    1084
    +        log.warning('open_uri: Invalid %s', uri)
    
    1085
    +        # TODO: UI error instead
    
    1058 1086
     
    
    1059 1087
         else:
    
    1060
    -        log.warning('Cant open URI: %s', uri)
    
    1088
    +        log.error('open_uri: No handler for %s', uri)
    
    1089
    +        # TODO: this is a bug, so, `raise` maybe?
    
    1090
    +
    
    1091
    +
    
    1092
    +def open_uri_externally(uri: str) -> None:
    
    1093
    +    if sys.platform == 'win32':
    
    1094
    +        webbrowser.open(uri, new=2)
    
    1095
    +    else:
    
    1096
    +        try:
    
    1097
    +            Gio.AppInfo.launch_default_for_uri(uri)
    
    1098
    +        except GLib.Error as err:
    
    1099
    +            log.info('open_uri_externally: '
    
    1100
    +                     "Couldn't launch default for %s: %s", uri, err)
    
    1101
    +
    
    1102
    +
    
    1103
    +def open_file_uri(uri: str) -> None:
    
    1104
    +    try:
    
    1105
    +        if sys.platform != 'win32':
    
    1106
    +            Gio.AppInfo.launch_default_for_uri(uri)
    
    1107
    +        else:
    
    1108
    +            os.startfile(uri)
    
    1109
    +    except Exception as err:
    
    1110
    +        log.info("Couldn't open file URI %s: %s", uri, err)
    
    1061 1111
     
    
    1062 1112
     
    
    1063 1113
     @catch_exceptions
    
    1064 1114
     def open_file(path: Union[str, Path]) -> None:
    
    1065
    -    if os.name == 'nt':
    
    1115
    +    if sys.platform == 'win32':
    
    1066 1116
             os.startfile(path)
    
    1067 1117
         else:
    
    1068 1118
             # Call str() to make it work with pathlib.Path
    
    1069 1119
             path = str(path)
    
    1070
    -        if not path.startswith('file://'):
    
    1071
    -            path = 'file://' + path
    
    1072
    -        Gio.AppInfo.launch_default_for_uri(path)
    
    1120
    +        if path.startswith('/'):
    
    1121
    +            open_file_uri('file://' + escape_iri_path(path))
    
    1122
    +        else:
    
    1123
    +            open_file_uri(path)
    
    1124
    +
    
    1125
    +
    
    1126
    +def filesystem_path_from_uri(uri: str) -> Optional[Path]:
    
    1127
    +    puri = parse_uri(uri)
    
    1128
    +    if puri.type != URIType.FILE:
    
    1129
    +        return None
    
    1130
    +
    
    1131
    +    netloc = puri.data['netloc']
    
    1132
    +    if netloc and netloc.lower() != 'localhost' and sys.platform != 'win32':
    
    1133
    +        return None
    
    1134
    +    return Path(puri.data['path'])
    
    1135
    +
    
    1136
    +
    
    1137
    +def get_file_path_from_dnd_dropped_uri(text: str) -> Optional[Path]:
    
    1138
    +    uri = text.strip('\r\n\x00')  # remove \r\n and NULL
    
    1139
    +    return filesystem_path_from_uri(uri)
    
    1073 1140
     
    
    1074 1141
     
    
    1075 1142
     def file_is_locked(path_to_file: str) -> bool:
    

  • gajim/common/regex.py
    ... ... @@ -19,10 +19,6 @@
    19 19
     INVALID_XML_CHARS_REGEX = re.compile(
    
    20 20
         '[\x00-\x08]|[\x0b-\x0c]|[\x0e-\x1f]|[\ud800-\udfff]|[\ufffe-\uffff]')
    
    21 21
     
    
    22
    -# at least one character in 3 parts (before @, after @, after .)
    
    23
    -STH_AT_STH_DOT_STH_REGEX = re.compile(
    
    24
    -    r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$')
    
    25
    -
    
    26 22
     
    
    27 23
     # Universal/Internationalized Resource Identifiers and their components, as
    
    28 24
     # defined in [ABNF for IRI References and IRIs]
    

  • gajim/common/setting_values.py
    ... ... @@ -36,6 +36,7 @@ class _ACCOUNT_DEFAULT:
    36 36
     
    
    37 37
     
    
    38 38
     BoolSettings = Literal[
    
    39
    +    'allow_open_file_uris',
    
    39 40
         'always_english_wikipedia',
    
    40 41
         'always_english_wiktionary',
    
    41 42
         'ascii_formatting',
    
    ... ... @@ -162,6 +163,7 @@ class _ACCOUNT_DEFAULT:
    162 163
     AllSettingsT = Union[str, int, bool, list[str]]
    
    163 164
     
    
    164 165
     APP_SETTINGS = {
    
    166
    +    'allow_open_file_uris': False,
    
    165 167
         'always_english_wikipedia': False,
    
    166 168
         'always_english_wiktionary': True,
    
    167 169
         'ascii_formatting': True,
    
    ... ... @@ -624,6 +626,7 @@ class WorkspaceSettings(TypedDict):
    624 626
     
    
    625 627
     ADVANCED_SETTINGS = {
    
    626 628
         'app': {
    
    629
    +        'allow_open_file_uris': _('Allows opening file:/ URIs when clicked.'),
    
    627 630
             'always_english_wikipedia': '',
    
    628 631
             'always_english_wiktionary': '',
    
    629 632
             'ascii_formatting': _(
    

  • gajim/common/structs.py
    ... ... @@ -42,7 +42,6 @@
    42 42
     from gajim.common.const import KindConstant
    
    43 43
     from gajim.common.const import PresenceShowExt
    
    44 44
     from gajim.common.const import URIType
    
    45
    -from gajim.common.const import URIAction
    
    46 45
     
    
    47 46
     
    
    48 47
     _T = TypeVar('_T')
    
    ... ... @@ -50,7 +49,9 @@
    50 49
     
    
    51 50
     class URI(NamedTuple):
    
    52 51
         type: URIType
    
    53
    -    action: Optional[URIAction] = None
    
    52
    +    source: str
    
    53
    +    query_type: str = ''
    
    54
    +    query_params: dict[str, str] = {}
    
    54 55
         data: Optional[Union[dict[str, str], str]] = None
    
    55 56
     
    
    56 57
     
    

  • gajim/common/styling.py
    ... ... @@ -16,6 +16,7 @@
    16 16
     
    
    17 17
     from typing import Union
    
    18 18
     from typing import Match
    
    19
    +from typing import Optional
    
    19 20
     
    
    20 21
     import string
    
    21 22
     import sys
    
    ... ... @@ -26,6 +27,9 @@
    26 27
     from gi.repository import GLib
    
    27 28
     
    
    28 29
     from gajim.common import regex
    
    30
    +from gajim.common.const import URIType
    
    31
    +from gajim.common.helpers import parse_uri as analyze_uri
    
    32
    +from gajim.common.helpers import validate_jid
    
    29 33
     from gajim.common.text_helpers import jid_to_iri
    
    30 34
     from gajim.gui.emoji_data import emoji_data
    
    31 35
     
    
    ... ... @@ -329,7 +333,7 @@ def _parse_uris(line: str,
    329 333
                     offset_bytes: int) -> list[BaseHyperlink]:
    
    330 334
         uris: list[BaseHyperlink] = []
    
    331 335
     
    
    332
    -    def make(start: int, end: int, is_jid: bool) -> BaseHyperlink:
    
    336
    +    def make(start: int, end: int, is_jid: bool) -> Optional[BaseHyperlink]:
    
    333 337
             if line[end - 1] == ',':
    
    334 338
                 # Trim one trailing comma
    
    335 339
                 end -= 1
    
    ... ... @@ -337,6 +341,9 @@ def make(start: int, end: int, is_jid: bool) -> BaseHyperlink:
    337 341
                 # Trim one trailing closing parenthesis if the match is preceded
    
    338 342
                 # by an opening one somewhere on the line
    
    339 343
                 end -= 1
    
    344
    +        if re.fullmatch('[^:]+:', line[start:end]):
    
    345
    +            # URIs that consist only of a scheme are thusly excluded
    
    346
    +            return None
    
    340 347
             return _make_hyperlink(line,
    
    341 348
                                    start,
    
    342 349
                                    end - 1,
    
    ... ... @@ -346,14 +353,9 @@ def make(start: int, end: int, is_jid: bool) -> BaseHyperlink:
    346 353
     
    
    347 354
         for match in URI_OR_JID_RX.finditer(line):
    
    348 355
             start, end = match.span()
    
    349
    -        if match.group('jid') is not None:
    
    350
    -            uris.append(make(start, end, True))
    
    351
    -        elif not re.fullmatch('[^:]+:', line[start:end]) and\
    
    352
    -            (not re.fullmatch('https?', match.group('scheme'), re.IGNORECASE) or
    
    353
    -             match.group('host')):
    
    354
    -            # URIs that consist only of a scheme are thusly excluded.
    
    355
    -            # http(s) URIs without host or with empty host are excluded too.
    
    356
    -            uris.append(make(start, end, False))
    
    356
    +        hyperlink = make(start, end, bool(match.group('jid')))
    
    357
    +        if hyperlink:
    
    358
    +            uris.append(hyperlink)
    
    357 359
     
    
    358 360
         return uris
    
    359 361
     
    
    ... ... @@ -422,7 +424,7 @@ def _make_hyperlink(line: str,
    422 424
                         end: int,
    
    423 425
                         offset: int,
    
    424 426
                         offset_bytes: int,
    
    425
    -                    is_jid: bool) -> BaseHyperlink:
    
    427
    +                    is_jid: bool) -> Optional[BaseHyperlink]:
    
    426 428
     
    
    427 429
         text = line[start:end + 1]
    
    428 430
     
    
    ... ... @@ -435,13 +437,23 @@ def _make_hyperlink(line: str,
    435 437
         uri = text
    
    436 438
         if is_jid:
    
    437 439
             cls_ = XMPPAddress
    
    440
    +        try:
    
    441
    +            validate_jid(text)
    
    442
    +        except ValueError:
    
    443
    +            return None
    
    438 444
             uri = jid_to_iri(text)
    
    439
    -    elif re.match('xmpp:', text, re.IGNORECASE):
    
    440
    -        cls_ = XMPPAddress
    
    441
    -    elif re.match('mailto:', text, re.IGNORECASE):
    
    442
    -        cls_ = MailAddress
    
    445
    +
    
    443 446
         else:
    
    444
    -        cls_ = Hyperlink
    
    447
    +        uri = text
    
    448
    +        auri = analyze_uri(uri)
    
    449
    +        if auri.type == URIType.XMPP:
    
    450
    +            cls_ = XMPPAddress
    
    451
    +        elif auri.type == URIType.MAIL:
    
    452
    +            cls_ = MailAddress
    
    453
    +        elif auri.type == URIType.INVALID:
    
    454
    +            return None
    
    455
    +        else:
    
    456
    +            cls_ = Hyperlink
    
    445 457
     
    
    446 458
         return cls_(start=start,
    
    447 459
                     start_byte=start_byte,
    

  • gajim/gtk/avatar_selector.py
    ... ... @@ -19,7 +19,6 @@
    19 19
     from typing import cast
    
    20 20
     from typing import Optional
    
    21 21
     
    
    22
    -import os
    
    23 22
     import logging
    
    24 23
     from enum import IntEnum
    
    25 24
     from enum import unique
    
    ... ... @@ -148,9 +147,9 @@ def _on_drag_data_received(self,
    148 147
             if target_type == 80:
    
    149 148
                 uri_split = selection.get_uris()  # Might be more than one
    
    150 149
                 path = get_file_path_from_dnd_dropped_uri(uri_split[0])
    
    151
    -            if not os.path.isfile(path):
    
    150
    +            if not path or not path.is_file():
    
    152 151
                     return
    
    153
    -            self.prepare_crop_area(path)
    
    152
    +            self.prepare_crop_area(str(path))
    
    154 153
     
    
    155 154
         @staticmethod
    
    156 155
         def _get_pixbuf_from_path(path: str) -> Optional[GdkPixbuf.Pixbuf]:
    

  • gajim/gtk/control.py
    ... ... @@ -19,7 +19,6 @@
    19 19
     from typing import Union
    
    20 20
     from typing import cast
    
    21 21
     
    
    22
    -import os
    
    23 22
     import logging
    
    24 23
     import time
    
    25 24
     
    
    ... ... @@ -491,13 +490,18 @@ def drag_data_file_transfer(self, selection: Gtk.SelectionData) -> None:
    491 490
             uri_splitted = selection.get_uris()
    
    492 491
             for uri in uri_splitted:
    
    493 492
                 path = get_file_path_from_dnd_dropped_uri(uri)
    
    494
    -            if not os.path.isfile(path):  # is it a file?
    
    493
    +            if not path:
    
    494
    +                self.add_info_message(
    
    495
    +                    _('The following address was not understood and was '
    
    496
    +                      'not uploaded: %s') % uri)
    
    497
    +                continue
    
    498
    +            if not path.is_file():  # is it a regular file?
    
    495 499
                     self.add_info_message(
    
    496 500
                         _('The following file could not be accessed and was '
    
    497 501
                           'not uploaded: %s') % path)
    
    498 502
                     continue
    
    499 503
     
    
    500
    -            app.interface.start_file_transfer(self.contact, path)
    
    504
    +            app.interface.start_file_transfer(self.contact, str(path))
    
    501 505
     
    
    502 506
         def get_our_nick(self) -> str:
    
    503 507
             if isinstance(self.contact, GroupchatParticipant):
    

  • gajim/gtk/history_export.py
    ... ... @@ -21,13 +21,13 @@
    21 21
     import time
    
    22 22
     from datetime import datetime
    
    23 23
     from pathlib import Path
    
    24
    -import sys
    
    25 24
     
    
    26 25
     from gi.repository import Gtk
    
    27 26
     
    
    28 27
     from gajim.common import app
    
    29 28
     from gajim.common import configpaths
    
    30 29
     from gajim.common.const import KindConstant
    
    30
    +from gajim.common.helpers import filesystem_path_from_uri
    
    31 31
     from gajim.common.helpers import make_path_from_jid
    
    32 32
     from gajim.common.i18n import _
    
    33 33
     from gajim.common.storage.archive import MessageExportRow
    
    ... ... @@ -203,11 +203,9 @@ def _set_complete(self) -> None:
    203 203
         def _on_file_set(self, button: Gtk.FileChooserButton) -> None:
    
    204 204
             uri = button.get_uri()
    
    205 205
             assert uri is not None
    
    206
    -
    
    207
    -        if sys.platform == 'win32':
    
    208
    -            self._export_directory = uri.removeprefix('file:///')
    
    209
    -        else:
    
    210
    -            self._export_directory = uri.removeprefix('file://')
    
    206
    +        path = filesystem_path_from_uri(uri)
    
    207
    +        assert path is not None
    
    208
    +        self._export_directory = str(path)
    
    211 209
     
    
    212 210
         def get_account_and_directory(self) -> tuple[str, str]:
    
    213 211
             assert self._account is not None
    

  • gajim/gtk/menus.py
    ... ... @@ -38,7 +38,7 @@
    38 38
     from gajim.common.i18n import _
    
    39 39
     from gajim.common.i18n import get_short_lang_code
    
    40 40
     from gajim.common.const import URIType
    
    41
    -from gajim.common.const import URIAction
    
    41
    +from gajim.common.const import XmppUriQuery
    
    42 42
     from gajim.common.structs import URI
    
    43 43
     from gajim.common.structs import VariantMixin
    
    44 44
     from gajim.common.modules.contacts import GroupchatContact
    
    ... ... @@ -269,7 +269,7 @@ def get_conv_action_context_menu(account: str,
    269 269
         submenu.append(item)
    
    270 270
     
    
    271 271
         item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link'))
    
    272
    -    value = GLib.Variant.new_strv([account, uri_text])
    
    272
    +    value = GLib.Variant.new_strv([account, f'https://{uri_text}'])
    
    273 273
         item.set_action_target_value(value)
    
    274 274
         submenu.append(item)
    
    275 275
     
    
    ... ... @@ -281,7 +281,7 @@ def get_conv_action_context_menu(account: str,
    281 281
     
    
    282 282
     def get_conv_uri_context_menu(account: str, uri: URI) -> Optional[Gtk.Menu]:
    
    283 283
         if uri.type == URIType.XMPP:
    
    284
    -        if uri.action == URIAction.JOIN:
    
    284
    +        if XmppUriQuery.from_str(uri.query_type) == XmppUriQuery.JOIN:
    
    285 285
                 context_menu = [
    
    286 286
                     ('copy-text', _('Copy XMPP Address')),
    
    287 287
                     ('groupchat-join', _('Join Groupchat')),
    

  • gajim/gtk/start_chat.py
    ... ... @@ -40,6 +40,8 @@
    40 40
     
    
    41 41
     from gajim.common import app
    
    42 42
     from gajim.common.const import Direction
    
    43
    +from gajim.common.const import URIType
    
    44
    +from gajim.common.helpers import parse_uri
    
    43 45
     from gajim.common.helpers import validate_jid
    
    44 46
     from gajim.common.helpers import to_user_string
    
    45 47
     from gajim.common.helpers import get_group_chat_nick
    
    ... ... @@ -549,10 +551,9 @@ def _on_search_changed(self, search_entry: Gtk.SearchEntry) -> None:
    549 551
                 return
    
    550 552
     
    
    551 553
             search_text = search_entry.get_text()
    
    552
    -        if search_text.startswith('xmpp:'):
    
    553
    -            search_text = search_text.removeprefix('xmpp:')
    
    554
    -            search_text = search_text.removesuffix('?join')
    
    555
    -            search_entry.set_text(search_text)
    
    554
    +        uri = parse_uri(search_text)
    
    555
    +        if uri.type == URIType.XMPP:
    
    556
    +            search_entry.set_text(uri.data['jid'])
    
    556 557
                 return
    
    557 558
     
    
    558 559
             if '@' in search_text:
    

  • gajim/gtk/vcard_grid.py
    ... ... @@ -30,6 +30,7 @@
    30 30
     from gajim.common.helpers import open_uri
    
    31 31
     from gajim.common.helpers import parse_uri
    
    32 32
     from gajim.common.structs import URI
    
    33
    +from gajim.common.text_helpers import escape_iri_path_segment
    
    33 34
     from gajim.gui.util import gtk_month
    
    34 35
     from gajim.gui.util import python_month
    
    35 36
     
    
    ... ... @@ -237,7 +238,7 @@ class ValueLabel(Gtk.Label):
    237 238
         def __init__(self, prop, account):
    
    238 239
             Gtk.Label.__init__(self)
    
    239 240
             self._prop = prop
    
    240
    -        self._uri: Optional[str] = None
    
    241
    +        self._uri: Optional[URI] = None
    
    241 242
             self._account = account
    
    242 243
             self.set_selectable(True)
    
    243 244
             self.set_xalign(0)
    
    ... ... @@ -252,24 +253,45 @@ def __init__(self, prop, account):
    252 253
                 self.set_value(prop.value)
    
    253 254
     
    
    254 255
         def set_value(self, value: str) -> None:
    
    256
    +        # EMAIL https://rfc-editor.org/rfc/rfc6350#section-6.4.2
    
    255 257
             if self._prop.name == 'email':
    
    256
    -            self._uri = URI(type=URIType.MAIL, data=value)
    
    257
    -            self.set_markup(value)
    
    258
    -
    
    259
    -        elif self._prop.name in ('impp', 'tel'):
    
    260
    -            self._uri = parse_uri(value)
    
    261
    -            self.set_markup(value)
    
    258
    +            uri = 'mailto:' + escape_iri_path_segment(value)
    
    259
    +            self.set_value_with_uri(value, uri)
    
    260
    +
    
    261
    +        # All the properties that can be (single) URIs:
    
    262
    +        # SOURCE    https://rfc-editor.org/rfc/rfc6350#section-6.1.3
    
    263
    +        # PHOTO     https://rfc-editor.org/rfc/rfc6350#section-6.2.4
    
    264
    +        # TEL       https://rfc-editor.org/rfc/rfc6350#section-6.4.1
    
    265
    +        # IMPP      https://rfc-editor.org/rfc/rfc6350#section-6.4.3
    
    266
    +        # TZ        https://rfc-editor.org/rfc/rfc6350#section-6.5.1
    
    267
    +        # GEO       https://rfc-editor.org/rfc/rfc6350#section-6.5.2
    
    268
    +        # LOGO      https://rfc-editor.org/rfc/rfc6350#section-6.6.3
    
    269
    +        # MEMBER    https://rfc-editor.org/rfc/rfc6350#section-6.6.5
    
    270
    +        # RELATED   https://rfc-editor.org/rfc/rfc6350#section-6.6.6
    
    271
    +        # SOUND     https://rfc-editor.org/rfc/rfc6350#section-6.7.5
    
    272
    +        # UID       https://rfc-editor.org/rfc/rfc6350#section-6.7.6
    
    273
    +        # URL       https://rfc-editor.org/rfc/rfc6350#section-6.7.8
    
    274
    +        # KEY       https://rfc-editor.org/rfc/rfc6350#section-6.8.1
    
    275
    +        # FBURL     https://rfc-editor.org/rfc/rfc6350#section-6.9.1
    
    276
    +        # CALADRURI https://rfc-editor.org/rfc/rfc6350#section-6.9.2
    
    277
    +        # CALURI    https://rfc-editor.org/rfc/rfc6350#section-6.9.3
    
    278
    +        elif self._prop.name in ('source', 'photo', 'tel', 'impp', 'tz', 'geo',
    
    279
    +                                 'logo', 'member', 'related', 'sound', 'uid',
    
    280
    +                                 'url', 'key', 'fburl', 'caladruri', 'caluri'):
    
    281
    +            self.set_value_with_uri(value, value)
    
    262 282
     
    
    263 283
             else:
    
    264 284
                 self.set_text(value)
    
    265 285
     
    
    266
    -    def set_markup(self, text: str) -> None:
    
    267
    -        if not text:
    
    268
    -            self.set_text('')
    
    286
    +    def set_value_with_uri(self, value: str, uri: str) -> None:
    
    287
    +        puri = parse_uri(uri)
    
    288
    +        if puri.type == URIType.INVALID:
    
    289
    +            self.set_text(value)
    
    269 290
                 return
    
    291
    +        self._uri = puri
    
    270 292
             super().set_markup('<a href="{}">{}</a>'.format(
    
    271
    -            GLib.markup_escape_text(text),
    
    272
    -            GLib.markup_escape_text(text)))
    
    293
    +            GLib.markup_escape_text(uri),
    
    294
    +            GLib.markup_escape_text(value)))
    
    273 295
     
    
    274 296
         def _on_activate_link(self, _label: Gtk.Label, _value: str) -> int:
    
    275 297
             open_uri(self._uri, self._account)
    

  • test/no_gui/test_styling.py
    1 1
     import unittest
    
    2 2
     
    
    3
    +from gajim import gui
    
    4
    +gui.init('gtk')
    
    5
    +
    
    6
    +from gajim.common import app  # Avoids circular imports from common.helpers
    
    3 7
     import gajim.common.styling as styling
    
    4 8
     from gajim.common.styling import PlainBlock
    
    5 9
     from gajim.common.styling import PreBlock
    
    ... ... @@ -287,7 +291,7 @@
    287 291
         'a:b',
    
    288 292
         'a-:b',
    
    289 293
         'a.:b',
    
    290
    -    'xmpp:conference.gajim.org'
    
    294
    +    'xmpp:conference.gajim.org',
    
    291 295
         'xmpp:asd@at',
    
    292 296
         'xmpp:asd@asd.at',
    
    293 297
         'xmpp:asd-asd@asd.asdasd.at.',
    
    ... ... @@ -297,6 +301,13 @@
    297 301
         'xmpp:%23room%25irc.example@biboumi.xmpp.example',
    
    298 302
         'xmpp:+15551234567@cheogram.com',
    
    299 303
         'xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message',
    
    304
    +    'geo:1,2',
    
    305
    +    'geo:1,2,3',
    
    306
    +    'file:/foo/bar/baz',  # xffm
    
    307
    +    'file:///foo/bar/baz',  # nautilus, rox
    
    308
    +    'file:///x:/foo/bar/baz',  # windows
    
    309
    +    'file://localhost/foo/bar/baz',
    
    310
    +    'file://nonlocalhost/foo/bar/baz',
    
    300 311
     
    
    301 312
         # These seem to be from https://mathiasbynens.be/demo/url-regex
    
    302 313
         'http://foo.com/blah_blah',
    
    ... ... @@ -401,6 +412,21 @@
    401 412
         'http://##',
    
    402 413
         'http://##/',
    
    403 414
         'http:///a',
    
    415
    +
    
    416
    +    'geo:1,',
    
    417
    +    'geo:,2',
    
    418
    +    #'geo:1,2,',  FIXME: wrongly parsed as valid
    
    419
    +    'geo:1,,3',
    
    420
    +    'geo:,2,3',
    
    421
    +    'geo:1,,',
    
    422
    +    'geo:,2,',
    
    423
    +    'geo:,,3',
    
    424
    +    'geo:,,',
    
    425
    +
    
    426
    +    'file:',
    
    427
    +    'file:a',
    
    428
    +    'file:a/',
    
    429
    +    'file:a/b',
    
    404 430
     ]
    
    405 431
     
    
    406 432
     
    
    ... ... @@ -498,9 +524,6 @@ def test_uris(self):
    498 524
     
    
    499 525
         def test_invalid_uris(self):
    
    500 526
             for foo in NONURIS + UNACCEPTABLE_URIS:
    
    501
    -            if foo == 'scheme:' or\
    
    502
    -               foo == 'http://':
    
    503
    -                continue  # these currently fial (FIXME)
    
    504 527
                 text = self.wrap(foo)
    
    505 528
                 hlinks = process_uris(text)
    
    506 529
                 if len(hlinks) == 0:
    
    ... ... @@ -518,8 +541,6 @@ def test_jids(self):
    518 541
     
    
    519 542
         def test_nonjids(self):
    
    520 543
             for foo in NONJIDS:
    
    521
    -            if foo == 'juliet@':
    
    522
    -                continue  # this currently fials (FIXME)
    
    523 544
                 text = self.wrap(foo)
    
    524 545
                 hlinks = process_uris(text)
    
    525 546
                 if len(hlinks) == 0: