Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_xep_0234.py @ 4231:e11b13418ba6
plugin XEP-0353, XEP-0234, jingle: WebRTC data channel signaling implementation:
Implement XEP-0343: Signaling WebRTC Data Channels in Jingle. The current version of the
XEP (0.3.1) has no implementation and contains some flaws. After discussing this on xsf@,
Daniel (from Conversations) mentioned that they had a sprint with Larma (from Dino) to
work on another version and provided me with this link:
https://gist.github.com/iNPUTmice/6c56f3e948cca517c5fb129016d99e74 . I have used it for my
implementation.
This implementation reuses work done on Jingle A/V call (notably XEP-0176 and XEP-0167
plugins), with adaptations. When used, XEP-0234 will not handle the file itself as it
normally does. This is because WebRTC has several implementations (browser for web
interface, GStreamer for others), and file/data must be handled directly by the frontend.
This is particularly important for web frontends, as the file is not sent from the backend
but from the end-user's browser device.
Among the changes, there are:
- XEP-0343 implementation.
- `file_send` bridge method now use serialised dict as output.
- New `BaseTransportHandler.is_usable` method which get content data and returns a boolean
(default to `True`) to tell if this transport can actually be used in this context (when
we are initiator). Used in webRTC case to see if call data are available.
- Support of `application` media type, and everything necessary to handle data channels.
- Better confirmation message, with file name, size and description when available.
- When file is accepted in preflight, it is specified in following `action_new` signal for
actual file transfer. This way, frontend can avoid the display or 2 confirmation
messages.
- XEP-0166: when not specified, default `content` name is now its index number instead of
a UUID. This follows the behaviour of browsers.
- XEP-0353: better handling of events such as call taken by another device.
- various other updates.
rel 441
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 06 Apr 2024 12:57:23 +0200 |
parents | 5a0bddfa34ac |
children | 79c8a70e1813 |
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_xep_0234.py Sat Apr 06 12:21:04 2024 +0200 +++ b/libervia/backend/plugins/plugin_xep_0234.py Sat Apr 06 12:57:23 2024 +0200 @@ -38,8 +38,9 @@ from libervia.backend.tools import xml_tools from libervia.backend.tools import utils from libervia.backend.tools import stream -from libervia.backend.tools.common import date_utils +from libervia.backend.tools.common import data_format, date_utils from libervia.backend.tools.common import regex +from libervia.backend.tools.common.utils import get_human_size from .plugin_xep_0166 import BaseApplicationHandler @@ -60,7 +61,11 @@ C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer"""), } -EXTRA_ALLOWED = {"path", "namespace", "file_desc", "file_hash", "hash_algo"} +# TODO: use a Pydantic model for extra +EXTRA_ALLOWED = { + "path", "namespace", "file_desc", "file_hash", "hash_algo", "webrtc", "call_data", + "size", "media_type" +} Range = namedtuple("Range", ("offset", "length")) @@ -84,8 +89,8 @@ host.bridge.add_method( "file_jingle_send", ".plugin", - in_sign="ssssa{ss}s", - out_sign="", + in_sign="ssssss", + out_sign="s", method=self._file_send, async_=True, ) @@ -202,26 +207,68 @@ return self.build_file_element(client, **file_data) async def parse_file_element( - self, client, file_elt, file_data=None, given=False, parent_elt=None, - keep_empty_range=False): - """Parse a <file> element and file dictionary accordingly + self, + client: SatXMPPEntity, + file_elt: domish.Element|None, + file_data: dict | None = None, + given: bool = False, + parent_elt: domish.Element | None = None, + keep_empty_range: bool = False + ) -> dict: + """Parse a <file> element and updates file dictionary accordingly. + + @param client: The SatXMPPEntity instance. + @param file_elt: The file element to parse. + @param file_data: Dict where the data will be set. Data are overwritten if they + exists (see @return for details). + If None, a new dict is created. + @param given: If True, prefix hash key with "given_". + @param parent_elt: Parent of the file element. If set, file_elt must not be set. + @param keep_empty_range: If True, keep empty range (i.e. range when offset + and length are None). Useful to know if a peer_jid can handle range. + + @return: file_data which is an updated or newly created dictionary with the following keys: + **name** (str) + Name of the file. Defaults to "unnamed" if not provided, "--" if the name is + "..", or a sanitized version if it contains path separators. + + **file_hash** (str, optional) + Hash of the file. Prefixed with "given_" if `given` is True. + + **hash_algo** (str, optional) + Algorithm used for the file hash. - @param file_data(dict, None): dict where the data will be set - following keys will be set (and overwritten if they already exist): - name, file_hash, hash_algo, size, mime_type, desc, path, namespace, range - if None, a new dict is created - @param given(bool): if True, prefix hash key with "given_" - @param parent_elt(domish.Element, None): parent of the file element - if set, file_elt must not be set - @param keep_empty_range(bool): if True, keep empty range (i.e. range when offset - and length are None). - Empty range is useful to know if a peer_jid can handle range - @return (dict): file_data - @trigger XEP-0234_parseFileElement(file_elt, file_data): can be used to parse new - elements - @raise exceptions.NotFound: there is not <file> element in parent_elt - @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT + **size** (int, optional) + Size of the file in bytes. + + **mime_type** (str, optional) + Media type of the file. + + **desc** (str, optional) + Description of the file. + + **path** (str, optional) + Path of the file. + + **namespace** (str, optional) + Namespace associated with the file. + + **transfer_range** (Range, optional) + Range of the file transfer. Present only if offset or length are specified, + or if `keep_empty_range` is True. + + **modified** (datetime, optional) + Last modified date of the file. + + @trigger XEP-0234_parseFileElement(file_elt, file_data): Can be used to parse new + elements. + + @raise exceptions.NotFound: Raised if there is no <file> element in `parent_elt` + or if required elements are missing. + @raise exceptions.DataError: Raised if `file_elt` URI is not NS_JINGLE_FT or if + the file element is invalid. """ + if parent_elt is not None: if file_elt is not None: raise exceptions.InternalError( @@ -234,7 +281,7 @@ else: if not file_elt or file_elt.uri != NS_JINGLE_FT: raise exceptions.DataError( - "invalid <file> element: {stanza}".format(stanza=file_elt.toXml()) + f"invalid <file> element: {file_elt.toXml() if file_elt else 'None'}" ) if file_data is None: @@ -247,11 +294,12 @@ pass name = file_data.get("name") - if name == "..": + if name is None: + file_data["name"] = "unnamed" + elif name == "..": # we don't want to go to parent dir when joining to a path - name = "--" - file_data["name"] = name - elif name is not None and ("/" in name or "\\" in name): + file_data["name"] = "--" + elif "/" in name or "\\" in name: file_data["name"] = regex.path_escape(name) try: @@ -302,33 +350,42 @@ def _file_send( self, - peer_jid, - filepath, - name="", - file_desc="", - extra=None, - profile=C.PROF_KEY_NONE, - ): + peer_jid_s: str, + filepath: str, + name: str, + file_desc: str, + extra_s: str, + profile: str, + ) -> defer.Deferred[str]: client = self.host.get_client(profile) - return defer.ensureDeferred(self.file_send( + extra = data_format.deserialise(extra_s) + d = defer.ensureDeferred(self.file_send( client, - jid.JID(peer_jid), + jid.JID(peer_jid_s), filepath, name or None, file_desc or None, - extra or None, + extra, )) + d.addCallback(data_format.serialise) + return d async def file_send( - self, client, peer_jid, filepath, name, file_desc=None, extra=None - ): + self, + client: SatXMPPEntity, + peer_jid: jid.JID, + filepath: str, + name: str|None, + file_desc: str|None = None, + extra: dict|None = None + ) -> dict: """Send a file using jingle file transfer - @param peer_jid(jid.JID): destinee jid - @param filepath(str): absolute path of the file - @param name(unicode, None): name of the file - @param file_desc(unicode, None): description of the file - @return (D(unicode)): progress id + @param peer_jid: destinee jid + @param filepath: absolute path of the file + @param name: name of the file + @param file_desc: description of the file + @return: progress id """ progress_id_d = defer.Deferred() if extra is None: @@ -336,24 +393,34 @@ if file_desc is not None: extra["file_desc"] = file_desc encrypted = extra.pop("encrypted", False) - await self._j.initiate( + + content = { + "app_ns": NS_JINGLE_FT, + "senders": self._j.ROLE_INITIATOR, + "app_kwargs": { + "filepath": filepath, + "name": name, + "extra": extra, + "progress_id_d": progress_id_d, + }, + } + + await self.host.trigger.async_point( + "XEP-0234_file_jingle_send", + client, peer_jid, content + ) + + session_id = await self._j.initiate( client, peer_jid, - [ - { - "app_ns": NS_JINGLE_FT, - "senders": self._j.ROLE_INITIATOR, - "app_kwargs": { - "filepath": filepath, - "name": name, - "extra": extra, - "progress_id_d": progress_id_d, - }, - } - ], + [content], encrypted = encrypted ) - return await progress_id_d + progress_id = await progress_id_d + return { + "progress": progress_id, + "session_id": session_id + } def _file_jingle_request( self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None, @@ -391,26 +458,76 @@ else: if hash_algo is not None: raise ValueError(_("file_hash must be set if hash_algo is set")) + + content = { + "app_ns": NS_JINGLE_FT, + "senders": self._j.ROLE_RESPONDER, + "app_kwargs": { + "filepath": filepath, + "name": name, + "extra": extra, + "progress_id_d": progress_id_d, + }, + } + await self._j.initiate( client, peer_jid, - [ - { - "app_ns": NS_JINGLE_FT, - "senders": self._j.ROLE_RESPONDER, - "app_kwargs": { - "filepath": filepath, - "name": name, - "extra": extra, - "progress_id_d": progress_id_d, - }, - } - ], + [content], ) return await progress_id_d # jingle callbacks + def _get_confirm_msg( + self, + client: SatXMPPEntity, + peer_jid: jid.JID, + file_data: dict + ) -> tuple[bool, str, str]: + """Get confirmation message to display to user. + + This is the message to show when a file sending request is received.""" + file_name = file_data.get('name') + file_size = file_data.get('size') + + if file_name: + file_name_msg = D_('wants to send you the file "{file_name}"').format( + file_name=file_name + ) + else: + file_name_msg = D_('wants to send you an unnamed file') + + if file_size is not None: + file_size_msg = D_("which has a size of {file_size_human}").format( + file_size_human=get_human_size(file_size) + ) + else: + file_size_msg = D_("which has an unknown size") + + file_description = file_data.get('desc') + if file_description: + description_msg = " Description: {}.".format(file_description) + else: + description_msg = "" + + if client.roster and peer_jid.userhostJID() not in client.roster: + is_in_roster = False + confirm_msg = D_( + "Somebody not in your contact list ({peer_jid}) {file_name_msg} {file_size_msg}.{description_msg} " + "Accepting this could leak your presence and possibly your IP address. Do you accept?" + ).format(peer_jid=peer_jid, file_name_msg=file_name_msg, file_size_msg=file_size_msg, description_msg=description_msg) + confirm_title = D_("File sent from an unknown contact") + else: + is_in_roster = True + confirm_msg = D_( + "{peer_jid} {file_name_msg} {file_size_msg}.{description_msg} Do you " + "accept?" + ).format(peer_jid=peer_jid, file_name_msg=file_name_msg, file_size_msg=file_size_msg, description_msg=description_msg) + confirm_title = D_("File Proposed") + + return (is_in_roster, confirm_msg, confirm_title) + async def jingle_preflight( self, client: SatXMPPEntity, @@ -435,26 +552,28 @@ # transfer (metadata are not used). We must check with other clients what is # actually send, and if XEP-0353 is used, and do a better integration. - if client.roster and peer_jid.userhostJID() not in client.roster: - confirm_msg = D_( - "Somebody not in your contact list ({peer_jid}) wants to do a " - '"{human_name}" session with you, this would leak your presence and ' - "possibly you IP (internet localisation), do you accept?" - ).format(peer_jid=peer_jid, human_name=self.human_name) - confirm_title = D_("File sent from an unknown contact") + try: + file_elt = next(description_elt.elements(NS_JINGLE_FT, "file")) + except StopIteration: + file_data = {} + else: + file_data = await self.parse_file_element(client, file_elt) + + is_in_roster, confirm_msg, confirm_title = self._get_confirm_msg( + client, peer_jid, file_data + ) + if is_in_roster: + action_type = C.META_TYPE_CONFIRM + action_subtype = C.META_TYPE_FILE + else: action_type = C.META_TYPE_NOT_IN_ROSTER_LEAK action_subtype = None - else: - confirm_msg = D_( - "{peer_jid} wants to send a file to you, do you accept?" - ).format(peer_jid=peer_jid) - confirm_title = D_("File Proposed") - action_type = C.META_TYPE_CONFIRM - action_subtype = C.META_TYPE_FILE + action_extra = { "type": action_type, "session_id": session_id, "from_jid": peer_jid.full(), + "file_data": file_data } if action_subtype is not None: action_extra["subtype"] = action_subtype @@ -506,6 +625,9 @@ progress_id_d.callback(self.get_progress_id(session, content_name)) content_data = session["contents"][content_name] application_data = content_data["application_data"] + if extra.get("webrtc"): + transport_data = content_data["transport_data"] + transport_data["webrtc"] = True assert "file_path" not in application_data application_data["file_path"] = filepath file_data = application_data["file_data"] = {} @@ -520,10 +642,15 @@ file_data["date"] = utils.xmpp_date() file_data["desc"] = extra.pop("file_desc", "") file_data["name"] = name - mime_type = mimetypes.guess_type(name, strict=False)[0] + mime_type = ( + file_data.get("media_type") or mimetypes.guess_type(name, strict=False)[0] + ) if mime_type is not None: file_data["mime_type"] = mime_type - file_data["size"] = os.path.getsize(filepath) + size = extra.pop("size", None) + if size is None: + size = os.path.getsize(filepath) + file_data["size"] = size if "namespace" in extra: file_data["namespace"] = extra["namespace"] if "path" in extra: @@ -557,7 +684,7 @@ session: dict, content_name: str, desc_elt: domish.Element, - ): + ) -> bool: """This method request confirmation for a jingle session""" content_data = session["contents"][content_name] senders = content_data["senders"] @@ -613,10 +740,21 @@ return False async def _file_receiving_request_conf( - self, client, session, content_data, content_name, file_data, file_elt - ): + self, + client: SatXMPPEntity, + session: dict, + content_data: dict, + content_name: str, + file_data: dict, + file_elt: domish.Element + ) -> bool: """parse file_elt, and handle user permission/file opening""" - await self.parse_file_element(client, file_elt, file_data, given=True) + transport_data = content_data["transport_data"] + webrtc = transport_data.get("webrtc", False) + # file may have been already accepted in preflight + file_accepted = session.get("file_accepted", False) + file_data = await self.parse_file_element(client, file_elt, file_data, given=True) + # FIXME: looks redundant with code done in self.parse_file_element try: hash_algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt) except exceptions.NotFound: @@ -625,32 +763,52 @@ except exceptions.NotFound: raise failure.Failure(exceptions.DataError) - if hash_algo is not None: - file_data["hash_algo"] = hash_algo - file_data["hash_hasher"] = hasher = self._hash.get_hasher(hash_algo) - file_data["data_cb"] = lambda data: hasher.update(data) - - try: - file_data["size"] = int(file_data["size"]) - except ValueError: - raise failure.Failure(exceptions.DataError) - - name = file_data["name"] - if "/" in name or "\\" in name: - log.warning( - "File name contain path characters, we replace them: {}".format(name) - ) - file_data["name"] = name.replace("/", "_").replace("\\", "_") - - content_data["application_data"]["file_data"] = file_data - - # now we actualy request permission to user - # deferred to track end of transfer finished_d = content_data["finished_d"] = defer.Deferred() - confirmed = await self._f.get_dest_dir( - client, session["peer_jid"], content_data, file_data, stream_object=True - ) + + if webrtc: + peer_jid = session["peer_jid"] + __, confirm_msg, confirm_title = self._get_confirm_msg( + client, peer_jid, file_data + ) + action_extra = { + "webrtc": webrtc, + "file_accepted": file_accepted, + "type": C.META_TYPE_FILE, + "session_id": session["id"], + "from_jid": peer_jid.full(), + "file_data": file_data, + "progress_id": file_data["progress_id"], + } + # we need a confirm dialog here and not a file dialog, as the file handling is + # managed by the frontends with webRTC. + confirmed = await xml_tools.defer_confirm( + self.host, + confirm_msg, + confirm_title, + profile=client.profile, + action_extra=action_extra + ) + else: + + if hash_algo is not None: + file_data["hash_algo"] = hash_algo + file_data["hash_hasher"] = hasher = self._hash.get_hasher(hash_algo) + file_data["data_cb"] = lambda data: hasher.update(data) + + try: + file_data["size"] = int(file_data["size"]) + except ValueError: + raise failure.Failure(exceptions.DataError) + + content_data["application_data"]["file_data"] = file_data + + # now we actualy request permission to user + + confirmed = await self._f.get_dest_dir( + client, session["peer_jid"], content_data, file_data, stream_object=True + ) + if confirmed: await self.host.trigger.async_point( "XEP-0234_file_receiving_request_conf", @@ -660,6 +818,7 @@ finished_d.addCallbacks( self._finished_cb, self._finished_eb, args, None, args ) + return confirmed async def jingle_handler(self, client, action, session, content_name, desc_elt): @@ -678,53 +837,62 @@ file_elt.addElement("range") elif action == self._j.A_SESSION_ACCEPT: assert not "stream_object" in content_data - file_data = application_data["file_data"] - file_path = application_data["file_path"] - senders = content_data["senders"] - if senders != session["role"]: - # we are receiving the file - try: - # did the responder specified the size of the file? - file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file")) - size_elt = next(file_elt.elements(NS_JINGLE_FT, "size")) - size = int(str(size_elt)) - except (StopIteration, ValueError): - size = None - # XXX: hash security is not critical here, so we just take the higher - # mandatory one - hasher = file_data["hash_hasher"] = self._hash.get_hasher() - progress_id = self.get_progress_id(session, content_name) - try: + transport_data = content_data["transport_data"] + if transport_data.get("webrtc"): + # WebRTC case is special, the file is transfered by the frontend + # implementation directly, there is nothing done in backend. + log.debug( + "We're using WebRTC datachannel, the frontend handles it from now on." + ) + else: + file_data = application_data["file_data"] + file_path = application_data["file_path"] + senders = content_data["senders"] + if senders != session["role"]: + # we are receiving the file + try: + # did the responder specified the size of the file? + file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file")) + size_elt = next(file_elt.elements(NS_JINGLE_FT, "size")) + size = int(str(size_elt)) + except (StopIteration, ValueError): + size = None + # XXX: hash security is not critical here, so we just take the higher + # mandatory one + hasher = file_data["hash_hasher"] = self._hash.get_hasher() + progress_id = self.get_progress_id(session, content_name) + try: + content_data["stream_object"] = stream.FileStreamObject( + self.host, + client, + file_path, + mode="wb", + uid=progress_id, + size=size, + data_cb=lambda data: hasher.update(data), + ) + except Exception as e: + self.host.bridge.progress_error( + progress_id, C.PROGRESS_ERROR_FAILED, client.profile + ) + await self._j.terminate( + client, self._j.REASON_FAILED_APPLICATION, session) + raise e + else: + # we are sending the file + size = file_data["size"] + # XXX: hash security is not critical here, so we just take the higher + # mandatory one + hasher = file_data["hash_hasher"] = self._hash.get_hasher() content_data["stream_object"] = stream.FileStreamObject( self.host, client, file_path, - mode="wb", - uid=progress_id, + uid=self.get_progress_id(session, content_name), size=size, data_cb=lambda data: hasher.update(data), ) - except Exception as e: - self.host.bridge.progress_error( - progress_id, C.PROGRESS_ERROR_FAILED, client.profile - ) - await self._j.terminate( - client, self._j.REASON_FAILED_APPLICATION, session) - raise e - else: - # we are sending the file - size = file_data["size"] - # XXX: hash security is not critical here, so we just take the higher - # mandatory one - hasher = file_data["hash_hasher"] = self._hash.get_hasher() - content_data["stream_object"] = stream.FileStreamObject( - self.host, - client, - file_path, - uid=self.get_progress_id(session, content_name), - size=size, - data_cb=lambda data: hasher.update(data), - ) + finished_d = content_data["finished_d"] = defer.Deferred() args = [client, session, content_name, content_data] finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)