# HG changeset patch # User Goffi # Date 1655468123 -7200 # Node ID 6952a002abc776da0e1641bab85c41acd2f983a8 # Parent 2033fa3c5b85fccc7b2eeed43066d1fafe58edc1 plugin XEP-424: Message Retractation implementation: rel 367 diff -r 2033fa3c5b85 -r 6952a002abc7 sat/plugins/plugin_xep_0424.py --- /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 . + +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 = """ + + + + + + + + """.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 []