Philipp Hörist pushed to branch master at gajim / gajim
Commits:
-
31421f74
by mjk at 2022-11-05T16:16:40+00:00
-
d94d8272
by mjk at 2022-11-05T21:14:00+00:00
-
c6714b64
by mjk at 2022-11-05T21:18:52+00:00
-
b5b81605
by mjk at 2022-11-05T21:18:52+00:00
-
0fda2adf
by mjk at 2022-11-05T21:18:52+00:00
-
f0feaba1
by mjk at 2022-11-05T21:18:52+00:00
-
69af2ce6
by mjk at 2022-11-05T21:18:52+00:00
-
710cab12
by mjk at 2022-11-05T21:19:12+00:00
-
7eaa8ae5
by mjk at 2022-11-05T21:20:28+00:00
-
225f6eca
by mjk at 2022-11-05T21:20:28+00:00
-
c497eae2
by mjk at 2022-11-05T21:20:36+00:00
-
da620714
by mjk at 2022-11-05T21:21:44+00:00
-
e93a928b
by mjk at 2022-11-05T21:21:44+00:00
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:
| ... | ... | @@ -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):
|
| ... | ... | @@ -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:
|
| ... | ... | @@ -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]
|
| ... | ... | @@ -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': _(
|
| ... | ... | @@ -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 |
| ... | ... | @@ -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,
|
| ... | ... | @@ -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]:
|
| ... | ... | @@ -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):
|
| ... | ... | @@ -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
|
| ... | ... | @@ -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')),
|
| ... | ... | @@ -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:
|
| ... | ... | @@ -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)
|
| 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:
|