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)