changeset 3801:6952a002abc7

plugin XEP-424: Message Retractation implementation: rel 367
author Goffi <goffi@goffi.org>
date Fri, 17 Jun 2022 14:15:23 +0200
parents 2033fa3c5b85
children 983df907d456
files sat/plugins/plugin_xep_0424.py
diffstat 1 files changed, 246 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0424.py	Fri Jun 17 14:15:23 2022 +0200
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Any
+import time
+from copy import deepcopy
+
+from twisted.words.protocols.jabber import xmlstream, jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco
+from zope.interface import implementer
+
+from sat.core.constants import Const as C
+from sat.core.i18n import _, D_
+from sat.core import exceptions
+from sat.core.core_types import SatXMPPEntity
+from sat.core.log import getLogger
+from sat.memory.sqla_mapping import History
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Retraction",
+    C.PI_IMPORT_NAME: "XEP-0424",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0334", "XEP-0424", "XEP-0428"],
+    C.PI_DEPENDENCIES: ["XEP-0422"],
+    C.PI_MAIN: "XEP_0424",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation Message Retraction"""),
+}
+
+NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:0"
+
+CATEGORY = "Privacy"
+NAME = "retract_history"
+LABEL = D_("Keep History of Retracted Messages")
+PARAMS = """
+    <params>
+    <individual>
+    <category name="{category_name}">
+        <param name="{name}" label="{label}" type="bool" value="false" />
+    </category>
+    </individual>
+    </params>
+    """.format(
+    category_name=CATEGORY, name=NAME, label=_(LABEL)
+)
+
+
+class XEP_0424(object):
+
+    def __init__(self, host):
+        log.info(_("XEP-0424 (Message Retraction) plugin initialization"))
+        self.host = host
+        host.memory.updateParams(PARAMS)
+        self._h = host.plugins["XEP-0334"]
+        self._f = host.plugins["XEP-0422"]
+        host.registerNamespace("message-retract", NS_MESSAGE_RETRACT)
+        host.trigger.add("messageReceived", self._messageReceivedTrigger, 100)
+        host.bridge.addMethod(
+            "messRetract",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._retract,
+            async_=True,
+        )
+
+    def getHandler(self, __):
+        return XEP_0424_handler()
+
+    def _retract(self, message_id: str, profile: str) -> None:
+        client = self.host.getClient(profile)
+        return defer.ensureDeferred(
+            self.retract(client, message_id)
+        )
+
+    def retractByOriginId(
+        self,
+        client: SatXMPPEntity,
+        dest_jid: jid.JID,
+        origin_id: str
+    ) -> None:
+        """Send a message retraction using origin-id
+
+        [retract] should be prefered: internal ID should be used as it is independant of
+        XEPs changes. However, in some case messages may not be stored in database
+        (notably for some components), and then this method can be used
+        @param origin_id: origin-id as specified in XEP-0359
+        """
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = dest_jid.full()
+        apply_to_elt = self._f.applyToElt(message_elt, origin_id)
+        apply_to_elt.addElement((NS_MESSAGE_RETRACT, "retract"))
+        self.host.plugins["XEP-0428"].addFallbackElt(
+            message_elt,
+            "[A message retraction has been requested, but your client doesn't support "
+            "it]"
+        )
+        self._h.addHintElements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+    async def retractByHistory(
+        self,
+        client: SatXMPPEntity,
+        history: History
+    ) -> None:
+        """Send a message retraction using History instance
+
+        This method is to use instead of [retract] when the history instance is already
+        retrieved. Note that the instance must have messages and subjets loaded
+        @param history: history instance of the message to retract
+        """
+        try:
+            origin_id = history.origin_id
+        except KeyError:
+            raise exceptions.FeatureNotFound(
+                f"message to retract doesn't have the necessary origin-id, the sending "
+                "client is probably not supporting message retraction."
+            )
+        else:
+            self.retractByOriginId(client, history.dest_jid, origin_id)
+            await self.retractDBHistory(client, history)
+
+    async def retract(
+        self,
+        client: SatXMPPEntity,
+        message_id: str,
+    ) -> None:
+        """Send a message retraction request
+
+        @param message_id: ID of the message
+            This ID is the Libervia internal ID of the message. It will be retrieve from
+            database to find the ID used by XMPP (i.e. XEP-0359's "origin ID"). If the
+            message is not found in database, an exception will be raised
+        """
+        if not message_id:
+            raise ValueError("message_id can't be empty")
+        history = await self.host.memory.storage.get(
+            client, History, History.uid, message_id,
+            joined_loads=[History.messages, History.subjects]
+        )
+        if history is None:
+            raise exceptions.NotFound(
+                f"message to retract not found in database ({message_id})"
+            )
+        await self.retractByHistory(client, history)
+
+    async def retractDBHistory(self, client, history: History) -> None:
+        """Mark an history instance in database as retracted
+
+        @param history: history instance
+            "messages" and "subjects" must be loaded too
+        """
+        # FIXME: should be keep history? This is useful to check why a message has been
+        #   retracted, but if may be bad if the user think it's really deleted
+        # we assign a new object to be sure to trigger an update
+        history.extra = deepcopy(history.extra) if history.extra else {}
+        history.extra["retracted"] = True
+        keep_history = self.host.memory.getParamA(
+            NAME, CATEGORY, profile_key=client.profile
+        )
+        old_version: Dict[str, Any] = {
+            "timestamp": time.time()
+        }
+        if keep_history:
+            old_version.update({
+                "messages": [m.serialise() for m in history.messages],
+                "subjects": [s.serialise() for s in history.subjects]
+            })
+
+        history.extra.setdefault("old_versions", []).append(old_version)
+        await self.host.memory.storage.delete(
+            history.messages + history.subjects,
+            session_add=[history]
+        )
+
+    async def _messageReceivedTrigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        fastened_elts = await self._f.getFastenedElts(client, message_elt)
+        if fastened_elts is None:
+            return True
+        for elt in fastened_elts.elements:
+            if elt.name == "retract" and elt.uri == NS_MESSAGE_RETRACT:
+                if fastened_elts.history is not None:
+                    source_jid = fastened_elts.history.source_jid
+                    from_jid = jid.JID(message_elt["from"])
+                    if source_jid.userhostJID() != from_jid.userhostJID():
+                        log.warning(
+                            f"Received message retraction from {from_jid.full()}, but "
+                            f"the message to retract is from {source_jid.full()}. This "
+                            f"maybe a hack attempt.\n{message_elt.toXml()}"
+                        )
+                        return False
+                break
+        else:
+            return True
+        if not await self.host.trigger.asyncPoint(
+            "XEP-0424_retractReceived", client, message_elt, elt, fastened_elts
+        ):
+            return False
+        if fastened_elts.history is None:
+            # we check history after the trigger because we may be in a component which
+            # doesn't store messages in database.
+            log.warning(
+                f"No message found with given origin-id: {message_elt.toXml()}"
+            )
+            return False
+        log.info(f"[{client.profile}] retracting message {fastened_elts.id!r}")
+        await self.retractDBHistory(client, fastened_elts.history)
+        # TODO: send bridge signal
+
+        return False
+
+
+@implementer(disco.IDisco)
+class XEP_0424_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, __, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_MESSAGE_RETRACT)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []