diff --git a/libervia/backend/core/xmpp.py b/libervia/backend/core/xmpp.py index 30dee0e2..79ad5d86 100644 --- a/libervia/backend/core/xmpp.py +++ b/libervia/backend/core/xmpp.py @@ -383,7 +383,7 @@ class SatXMPPEntity(core_types.SatXMPPEntity): entity.identityHandler = SatIdentityHandler() entity.identityHandler.setHandlerParent(entity) - entity.discoHandler = disco.DiscoHandler() + entity.discoHandler = LiberviaDiscoHandler() entity.discoHandler.setHandlerParent(entity) log.debug(_("setting plugins parents")) @@ -2290,8 +2290,7 @@ class SatPresenceProtocol(xmppim.PresenceClientProtocol): server_jid = jid.JID(from_jid.host) self.host.memory.disco._jid_disco_cache[server_jid] = (None, time.time()) log.debug( - f"Server {server_jid} marked as unreachable from " - "presence error" + f"Server {server_jid} marked as unreachable from presence error" ) except Exception: log.exception("Can't handle presence error") @@ -2436,3 +2435,26 @@ class SatIdentityHandler(XMPPHandler): def getDiscoItems(self, requestor, target, nodeIdentifier=""): return [] + + +class LiberviaDiscoHandler(disco.DiscoHandler): + + def info( + self, + requestor: jid.JID | None, + target: jid.JID | None, + nodeIdentifier: str, + ) -> defer.Deferred[list]: + """DiscoHandler.info with a trigger. + + The trigger let plugins modify disco behaviour, notably used by + ``plugin_xep_0115`` to return cached disco infos. + """ + cont, trigger_ret = G.host.trigger.return_point( + "disco_info", requestor, target, nodeIdentifier + ) + if not cont: + trigger_ret = cast(defer.Deferred[list], trigger_ret) + return trigger_ret + + return super().info(requestor, target, nodeIdentifier) diff --git a/libervia/backend/plugins/plugin_xep_0115.py b/libervia/backend/plugins/plugin_xep_0115.py index 68f4e241..83f2c027 100644 --- a/libervia/backend/plugins/plugin_xep_0115.py +++ b/libervia/backend/plugins/plugin_xep_0115.py @@ -20,7 +20,7 @@ from base64 import b64encode from dataclasses import dataclass, field from hashlib import sha1 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from twisted.internet import defer, error from twisted.words.protocols.jabber import jid @@ -30,6 +30,7 @@ from tx_xmpp import data_form, disco, itx_xmpp from zope.interface import implementer from libervia.backend import G +from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import _ @@ -97,14 +98,20 @@ class XEP_0115: self.host = host log.info(_("Plugin XEP_0115 initialization")) host.trigger.add("Presence send", self._presence_trigger) + host.trigger.add("disco_info", self._disco_info_trigger) def get_handler(self, client: SatXMPPEntity) -> "XEP_0115_handler": return XEP_0115_handler(self) async def _prepare_caps(self, client: SatXMPPEntity) -> None: - # we have to calculate hash for client - # because disco infos/identities may change between clients + """Generate our own capability hash for a client. + Hash is generated per client instance as disco infos/identities may change + (plugins loaded/unloaded, config changes, etc.). + + @param client: the client entity to generate the hash for. + @raises exceptions.InternalError: The client's own disco info is malformed. + """ data = client.get_plugin_data(IMPORT_NAME, CapsClientData) # optimize check @@ -115,19 +122,23 @@ class XEP_0115: else: log.warning(_("Caps optimisation not available")) - # hash generation - assert data.caps_hash is None, "caps_hash already set" + old_hash = data.caps_hash _infos = await client.discoHandler.info(client.jid, client.jid, "") disco_infos = disco.DiscoInfo() for item in _infos: disco_infos.append(item) - cap_hash = self.generate_hash(disco_infos) - data.caps_hash = cap_hash - log.info( - "Our capability hash has been generated: [{cap_hash}]".format( - cap_hash=cap_hash + + # Validate our own disco info before hashing. + if not self._validate_caps(client.jid, "", "", disco_infos): + raise exceptions.InternalError( + "Our own disco#info response is malformed. Caps cannot be generated." ) - ) + + cap_hash = self.generate_hash(disco_infos) + if cap_hash != old_hash: + log.info(f"Capability hash changed: [{old_hash}] -> [{cap_hash}]") + data.caps_hash = cap_hash + log.info(f"Our capability hash has been generated: [{cap_hash}]") log.debug("Generating capability domish.Element") c_elt = domish.Element((NS_ENTITY_CAPABILITY, "c")) c_elt["hash"] = "sha-1" @@ -142,6 +153,34 @@ class XEP_0115: client, client.jid, C.ENTITY_CAP_HASH, cap_hash ) + def _disco_info_trigger( + self, + requestor: jid.JID | None, + target: jid.JID | None, + nodeIdentifier: str, + ) -> tuple[Literal[True], None] | tuple[Literal[False], defer.Deferred[list]]: + """Callback for the ``disco_info`` trigger. + + Intercepts caps node queries to return the cached disco info directly. + + @param requestor: entity that sent the disco request + @param target: entity the disco request was sent to + @param nodeIdentifier: optional node being queried + @return: ``(False, deferred)`` with cached disco items for known caps queries, or + ``(True, None)`` to let normal flow continue + """ + # Check if this is a caps node query (format: node#ver) + if nodeIdentifier and "#" in nodeIdentifier: + parts = nodeIdentifier.rsplit("#", 1) + if len(parts) == 2: + caps_node, cap_ver = parts + # Only respond if this matches our caps node + if caps_node == C.APP_URL and cap_ver in G.host.memory.disco.hashes: + disco_infos = G.host.memory.disco.hashes[cap_ver] + # Return all items from the cached disco info + return False, defer.succeed(list(disco_infos)) + return True, None + def _presence_add_elt(self, client: SatXMPPEntity, obj: domish.Element) -> None: data = client.get_plugin_data(IMPORT_NAME, CapsClientData) if data.caps_optimize: @@ -164,6 +203,7 @@ class XEP_0115: ) presence_d.addCallback(lambda __: self._presence_add_elt(client, obj)) + presence_d.addErrback(G.host.log_errback) return True def _update(self, presence_elt: domish.Element, client: SatXMPPEntity) -> None: @@ -179,7 +219,10 @@ class XEP_0115: @param presence_elt: element to parse. """ from_jid = jid.JID(presence_elt["from"]) - c_elem = next(presence_elt.elements(NS_ENTITY_CAPABILITY, "c")) + c_elem = next(presence_elt.elements(NS_ENTITY_CAPABILITY, "c"), None) + if c_elem is None: + # Presence without caps, nothing to do + return try: c_ver = c_elem["ver"] c_hash = c_elem["hash"] @@ -422,9 +465,7 @@ class XEP_0115_handler(XMPPHandler): def _caps_node_eb(self, failure_, server_jid, ver): """Errback for caps node query.""" - log.warning( - f"Failed to query caps node for {server_jid} (ver={ver}): {failure_}" - ) + log.warning(f"Failed to query caps node for {server_jid} (ver={ver}): {failure_}") def connectionInitialized(self) -> None: assert self.xmlstream is not None @@ -449,10 +490,10 @@ class XEP_0115_handler(XMPPHandler): else: # Query the caps node to get disco info (XEP-0115 ยง6.2) caps_node = f"{node}#{ver}" if node else ver - log.debug( - f"Querying caps node {caps_node} for server {server_jid}" + log.debug(f"Querying caps node {caps_node} for server {server_jid}") + d = self.client.disco.requestInfo( + server_jid, nodeIdentifier=caps_node ) - d = self.client.disco.requestInfo(server_jid, nodeIdentifier=caps_node) d.addCallback(self._caps_node_cb, ver) d.addErrback(self._caps_node_eb, server_jid, ver) @@ -479,19 +520,6 @@ class XEP_0115_handler(XMPPHandler): @param nodeIdentifier: The optional node being queried. @return: List of disco items (features, identities, extensions). """ - # Check if this is a caps node query (format: node#ver) - if nodeIdentifier and "#" in nodeIdentifier: - parts = nodeIdentifier.rsplit("#", 1) - if len(parts) == 2: - caps_node, cap_ver = parts - # Only respond if this matches our caps node - if ( - caps_node == C.APP_URL - and cap_ver in self.plugin_parent.host.memory.disco.hashes - ): - disco_infos = self.plugin_parent.host.memory.disco.hashes[cap_ver] - # Return all items from the cached disco info - return list(disco_infos) # For non-caps queries, return our caps features return [ diff --git a/tests/unit/test_plugin_xep_0115.py b/tests/unit/test_plugin_xep_0115.py index ad8babf8..7d8b9492 100644 --- a/tests/unit/test_plugin_xep_0115.py +++ b/tests/unit/test_plugin_xep_0115.py @@ -146,10 +146,8 @@ class TestXEP0115Handler: assert disco.DiscoFeature(NS_ENTITY_CAPABILITY) in result assert disco.DiscoFeature(NS_CAPS_OPTIMIZE) in result - def test_getDiscoInfo_caps_node_query(self, host, xep_0115): - """getDiscoInfo returns cached disco info for caps node queries.""" - handler = xep_0115.get_handler(MagicMock()) - + def test_disco_info_trigger_caps_node_query(self, host, xep_0115): + """_disco_info_trigger returns cached disco info for caps node queries.""" # Create and cache some disco info disco_infos = disco.DiscoInfo() disco_infos.append(disco.DiscoIdentity("client", "pc", "Test Client")) @@ -161,9 +159,14 @@ class TestXEP0115Handler: # Query with caps node format node_identifier = f"{C.APP_URL}#{cap_hash}" - result = handler.getDiscoInfo(MagicMock(), MagicMock(), node_identifier) + cont, deferred = xep_0115._disco_info_trigger( + MagicMock(), MagicMock(), node_identifier + ) - # Should return the cached disco info items + # Should not continue, return cached disco info + assert cont is False + assert deferred is not None + result = deferred.result assert len(result) > 0 assert any(isinstance(item, disco.DiscoIdentity) for item in result) assert any(isinstance(item, disco.DiscoFeature) for item in result) @@ -177,17 +180,26 @@ class TestXEP0115Handler: assert disco.DiscoFeature("http://jabber.org/protocol/caps") in features assert disco.DiscoFeature("http://jabber.org/protocol/disco#info") in features - def test_getDiscoInfo_unknown_caps_node(self, xep_0115): - """getDiscoInfo returns caps features for unknown caps nodes.""" - handler = xep_0115.get_handler(MagicMock()) - + def test_disco_info_trigger_unknown_caps_node(self, xep_0115): + """_disco_info_trigger lets unknown caps nodes pass through.""" # Query with unknown hash node_identifier = f"{C.APP_URL}#unknownhash123" - result = handler.getDiscoInfo(MagicMock(), MagicMock(), node_identifier) + cont, deferred = xep_0115._disco_info_trigger( + MagicMock(), MagicMock(), node_identifier + ) - # Should return just the caps features, not cached info - assert disco.DiscoFeature(NS_ENTITY_CAPABILITY) in result - assert disco.DiscoFeature(NS_CAPS_OPTIMIZE) in result + # Should continue, let normal flow proceed + assert cont is True + assert deferred is None + + def test_disco_info_trigger_non_caps_node(self, xep_0115): + """_disco_info_trigger ignores non-caps nodes.""" + cont, deferred = xep_0115._disco_info_trigger( + MagicMock(), MagicMock(), "some:other:node" + ) + + assert cont is True + assert deferred is None def test_getDiscoItems_empty(self, xep_0115): """getDiscoItems returns empty list (caps nodes are virtual)."""