Mercurial > libervia-pubsub
diff sat_pubsub/bookmark_compat.py @ 491:4e8e8788bc86
Bookmark compatibility layer:
The new `bookmark_compat` module add a compatibility layer between XEP-0048 (with
XEP-0049 private XML storage) and XEP-0402, i.e. it implements the
`urn:xmpp:bookmarks:1#compat` feature.
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 21 Nov 2024 11:03:51 +0100 |
parents | |
children | 468b7cd6c344 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/bookmark_compat.py Thu Nov 21 11:03:51 2024 +0100 @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2015-2024 Jérôme Poisson + + +# 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/>. + +"This module implements a compatibility layer for XEP-0048." + + +from twisted.internet import defer +from twisted.python import log +from twisted.words.protocols.jabber import error as jabber_error, jid, xmlstream +from twisted.words.xish import domish +from wokkel import disco, iwokkel, pubsub +from wokkel.iwokkel import IPubSubService +from zope.interface import implementer + +from sat_pubsub import const + +from . import error + +NS_IQ_PRIVATE = "jabber:iq:private" +NS_STORAGE_BOOKMARKS = "storage:bookmarks" +NS_BOOKMARKS2 = "urn:xmpp:bookmarks:1" +NS_BOOKMARKS_COMPAT = f"{NS_BOOKMARKS2}#compat" +IQ_PRIVATE_GET = f'/iq[@type="get"]/query[@xmlns="{NS_IQ_PRIVATE}"]' +IQ_PRIVATE_SET = f'/iq[@type="set"]/query[@xmlns="{NS_IQ_PRIVATE}"]' + + +@implementer(iwokkel.IDisco) +class BookmarkCompatHandler(disco.DiscoClientProtocol): + + def __init__(self, service_jid): + super().__init__() + self.backend = None + + def connectionInitialized(self): + for handler in self.parent.handlers: + if IPubSubService.providedBy(handler): + self._pubsub_service = handler + break + self.backend = self.parent.parent.getServiceNamed("backend") + self.xmlstream.addObserver(IQ_PRIVATE_GET, self._on_get) + self.xmlstream.addObserver(IQ_PRIVATE_SET, self._on_set) + + def _on_get(self, iq_elt: domish.Element) -> None: + """Handle incoming legacy IQ GET requests for bookmarks. + + This method processes legacy XEP-0048 IQ GET requests. It does some checks and + then proceeds to return the bookmarks. + + @param iq_elt: The incoming IQ element. + """ + if not iq_elt.delegated: + return + assert self.xmlstream is not None + iq_elt.handled = True + from_jid = jid.JID(iq_elt["from"]) + to_jid = jid.JID(iq_elt["to"]) + if from_jid.userhostJID() != to_jid: + msg = ( + f"{from_jid.userhost()} is not allowed to access private storage of " + f"{to_jid.userhost()}!" + ) + log.msg(f"Hack attempt? {msg}") + error_elt = jabber_error.StanzaError("forbidden", text=msg).toResponse(iq_elt) + self.xmlstream.send(error_elt) + return + + query_elt = iq_elt.query + assert query_elt is not None + + storage_elt = query_elt.firstChildElement() + if ( + storage_elt is None + or storage_elt.name != "storage" + or storage_elt.uri != NS_STORAGE_BOOKMARKS + ): + error_elt = jabber_error.StanzaError( + "not-allowed", + text=( + f'"{NS_STORAGE_BOOKMARKS}" is the only private XML storage allowed ' + "on this server" + ), + ).toResponse(iq_elt) + self.xmlstream.send(error_elt) + return + + defer.ensureDeferred(self.return_bookmarks(iq_elt, to_jid)) + + async def return_bookmarks(self, iq_elt: domish.Element, requestor: jid.JID) -> None: + """Send IQ result for bookmark request on private XML. + + Retrieve bookmark from Bookmarks2 PEP node, convert them to private XML + XEP-0048 format, and send the IQ result. + @param iq_elt: The incoming IQ element. + @param requestor: The JID of the entity requesting the bookmarks. + """ + assert self.backend is not None + assert self.xmlstream is not None + items, __ = await self.backend.getItems( + NS_BOOKMARKS2, requestor, requestor, ext_data={"pep": True} + ) + iq_result_elt = xmlstream.toResponse(iq_elt, "result") + + query_elt = iq_result_elt.addElement((NS_IQ_PRIVATE, "query")) + storage_elt = query_elt.addElement((NS_STORAGE_BOOKMARKS, "storage")) + + # For simply add all bookmarks to get XEP-0048 private XML elements. + for item_elt in items: + conference_elt = next(item_elt.elements(NS_BOOKMARKS2, "conference"), None) + if conference_elt is not None: + # The namespace is not the same for XEP-0048 + conference_elt.uri = NS_STORAGE_BOOKMARKS + for elt in conference_elt.children: + elt.uri = NS_STORAGE_BOOKMARKS + conference_elt["jid"] = item_elt["id"] + storage_elt.addChild(conference_elt) + else: + log.msg( + "Warning: Unexpectedly missing conference element: " + f"{item_elt.toXml()}" + ) + + self.xmlstream.send(iq_result_elt) + + def _on_set(self, iq_elt: domish.Element) -> None: + if not iq_elt.delegated: + return + assert self.xmlstream is not None + iq_elt.handled = True + from_jid = jid.JID(iq_elt["from"]) + to_jid = jid.JID(iq_elt["to"]) + if from_jid.userhostJID() != to_jid: + msg = ( + f"{from_jid.userhost()} is not allowed to access private storage of " + f"{to_jid.userhost()}!" + ) + log.msg(f"Hack attempt? {msg}") + error_elt = jabber_error.StanzaError("forbidden", text=msg).toResponse(iq_elt) + self.xmlstream.send(error_elt) + return + query_elt = iq_elt.query + assert query_elt is not None + storage_elt = query_elt.firstChildElement() + if ( + storage_elt is None + or storage_elt.name != "storage" + or storage_elt.uri != NS_STORAGE_BOOKMARKS + ): + error_elt = jabber_error.StanzaError( + "not-allowed", + text=( + f'"{NS_STORAGE_BOOKMARKS}" is the only private XML storage allowed ' + "on this server" + ), + ).toResponse(iq_elt) + self.xmlstream.send(error_elt) + return + defer.ensureDeferred(self.on_set(iq_elt, from_jid)) + + async def publish_bookmarks( + self, iq_elt: domish.Element, items: list[domish.Element], requestor: jid.JID + ) -> None: + """Publish bookmarks on Bookmarks2 PEP node""" + assert self.backend is not None + assert self.xmlstream is not None + try: + await self.backend.publish( + NS_BOOKMARKS2, + items, + requestor, + options={const.OPT_ACCESS_MODEL: const.VAL_AMODEL_WHITELIST}, + pep=True, + recipient=requestor, + ) + except error.NodeNotFound: + # The node doesn't exist, we create it + await self.backend.createNode( + NS_BOOKMARKS2, + requestor, + options={const.OPT_ACCESS_MODEL: const.VAL_AMODEL_WHITELIST}, + pep=True, + recipient=requestor, + ) + await self.publish_bookmarks(iq_elt, items, requestor) + except Exception as e: + log.err(f"Error while publishing converted bookmarks: {e}") + error_elt = jabber_error.StanzaError( + "internal-server-error", + text=(f"Something went wrong while publishing the bookmark items: {e}"), + ).toResponse(iq_elt) + self.xmlstream.send(error_elt) + raise e + + async def on_set(self, iq_elt: domish.Element, requestor: jid.JID) -> None: + """Handle IQ set request for bookmarks on private XML. + + This method processes an IQ set request to update bookmarks stored in private XML. + It extracts conference elements from the request, transforms them into XEP-0402 + format, and publishes them to XEP-0402 PEP node. + + @param iq_elt: The incoming IQ element containing the set request. + @param requestor: The JID of the entity making the request. + """ + # TODO: We should check if items already exist in Bookmarks 2 and avoid updating + # them if there is no change. However, considering that XEP-0048 is deprecated and + # active implementations without XEP-0402 are rare, it might not be worth the + # trouble. + assert self.backend is not None + assert self.xmlstream is not None + query_elt = iq_elt.query + assert query_elt is not None + # <storage> presence has been checked in ``_on_set``, we know that we have it + # here. + storage_elt = next(query_elt.elements(NS_STORAGE_BOOKMARKS, "storage")) + + items = [] + for conference_elt in storage_elt.elements(NS_STORAGE_BOOKMARKS, "conference"): + item_elt = domish.Element((pubsub.NS_PUBSUB, "item")) + try: + item_elt["id"] = conference_elt["jid"] + except AttributeError: + log.msg( + "Warning: ignoring <conference> with missing jid: " + f"conference_elt.toXml()" + ) + continue + new_conference_elt = item_elt.addElement((NS_BOOKMARKS2, "conference")) + for attr, value in conference_elt.attributes.items(): + if attr == "jid": + continue + new_conference_elt[attr] = value + for child in conference_elt.children: + new_child = domish.Element((NS_BOOKMARKS2, child.name)) + new_child.addContent(str(child)) + new_conference_elt.addChild(new_child) + items.append(item_elt) + + await self.publish_bookmarks(iq_elt, items, requestor) + + iq_result_elt = xmlstream.toResponse(iq_elt, "result") + self.xmlstream.send(iq_result_elt) + + def getDiscoInfo(self, requestor, service, nodeIdentifier=""): + return [ + disco.DiscoFeature(NS_IQ_PRIVATE), + disco.DiscoFeature(NS_BOOKMARKS_COMPAT), + ] + + def getDiscoItems(self, requestor, service, nodeIdentifier=""): + return []