diff --git a/libervia/backend/plugins/plugin_xep_0499.py b/libervia/backend/plugins/plugin_xep_0499.py index b561f9c1..d3eea70a 100644 --- a/libervia/backend/plugins/plugin_xep_0499.py +++ b/libervia/backend/plugins/plugin_xep_0499.py @@ -17,26 +17,19 @@ # along with this program. If not, see . from collections.abc import Callable, Coroutine, Iterable -from typing import ( - TYPE_CHECKING, - ClassVar, - Final, - Literal, - NamedTuple, - Self, - cast, -) +from typing import ClassVar, Final, Literal, NamedTuple, Self, TYPE_CHECKING, cast from pydantic import BaseModel, Field, RootModel, model_validator from sqlalchemy.exc import SQLAlchemyError from twisted.internet import defer -from twisted.words.protocols.jabber.jid import JID from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber.jid import JID from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish -from tx_xmpp import disco, data_form, pubsub +from tx_xmpp import data_form, disco, pubsub 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 SatXMPPComponent, SatXMPPEntity @@ -45,6 +38,7 @@ from libervia.backend.core.log import getLogger from libervia.backend.memory.sqla_mapping import PubsubItem, PubsubNode from libervia.backend.models.types import JIDType from libervia.backend.plugins.plugin_xep_0060 import NodeMetadata +from libervia.backend.plugins.plugin_xep_0496 import LINK_VAR, PARENT_VAR, XEP_0496 from libervia.backend.tools import utils if TYPE_CHECKING: @@ -60,7 +54,7 @@ PLUGIN_INFO = { C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0499"], C.PI_DEPENDENCIES: [ - "XEP-0060", + "XEP-0060", "XEP-0496" ], C.PI_RECOMMENDATIONS: [], C.PI_MAIN: "XEP_0499", @@ -71,9 +65,6 @@ PLUGIN_INFO = { } NS_PUBSUB_EXT_DISCO: Final = "urn:xmpp:pubsub-ext-disco:0" -NS_PUBSUB_RELATIONSHIPS: Final = "urn:xmpp:pubsub-relationships:0" -PARENT_VAR = f"{{{NS_PUBSUB_RELATIONSHIPS}}}parent" -LINK_VAR = f"{{{NS_PUBSUB_RELATIONSHIPS}}}link" class ExtDiscoMetadata(NodeMetadata): @@ -95,18 +86,35 @@ class ExtDiscoMetadata(NodeMetadata): metadata.link = form.get(LINK_VAR) return metadata - def to_data_form(self) -> data_form.Form: + def to_data_form(self, full_metadata: bool = False) -> data_form.Form: """Convert this instance to a data form. + @param full_metadata: If True, include all metadata fields. If False, only include + relationship fields (parent and link). @return: Data form representation of this instance. """ - form = super().to_data_form() + if full_metadata: + form = super().to_data_form() + else: + form = data_form.Form( + formType="result", formNamespace=pubsub.NS_PUBSUB_META_DATA + ) + if self.parent is not None: form.addField(data_form.Field(var=PARENT_VAR, value=self.parent)) if self.link is not None: form.addField(data_form.Field(var=LINK_VAR, value=self.link)) return form + def to_element(self, full_metadata: bool = False) -> domish.Element: + """Generate the element corresponding to this form. + + @param full_metadata: If True, include all metadata fields. If False, only include + relationship fields (parent and link). + @return: XML element representation of this instance. + """ + return self.to_data_form(full_metadata).toElement() + class ExtDiscoOptions(BaseModel): """Pydantic model for the pubsub extended discovery form fields.""" @@ -320,29 +328,27 @@ class DiscoPubsubNode(BaseModel): return disco_node - def to_element(self) -> domish.Element: + def to_element(self, full_metadata: bool = False) -> domish.Element: item_elt = domish.Element((disco.NS_DISCO_ITEMS, "item")) item_elt["jid"] = self.jid.full() item_elt["node"] = self.name - item_elt.addChild(self.metadata.to_element()) + item_elt.addChild(self.metadata.to_element(full_metadata)) return item_elt - def to_elements( - self, - ) -> list[domish.Element]: + def to_elements(self, full_metadata: bool = False) -> list[domish.Element]: """Return this elements and all its descendants and linking nodes. @param parent_node: Parent of this node, None if it's a root node. @param linked_node: Node this node is linking to, if any. @return: This node, its descendants and linking nodes. """ - elements = [self.to_element()] + elements = [self.to_element(full_metadata)] if self.linking_nodes is not None: for linking_node in self.linking_nodes: - elements.append(linking_node.to_element()) + elements.append(linking_node.to_element(full_metadata)) if self.children is not None: for child in self.children: - elements.extend(child.to_elements()) + elements.extend(child.to_elements(full_metadata)) return elements @@ -455,11 +461,11 @@ class ExtDiscoResult(RootModel): return cls(root=result_items) - def to_elements(self) -> list[domish.Element]: + def to_elements(self, full_metadata: bool = False) -> list[domish.Element]: elements = [] for item in self.root: if isinstance(item, DiscoPubsubNode): - elements.extend(item.to_elements()) + elements.extend(item.to_elements(full_metadata)) else: elements.append(item.to_element()) return elements @@ -484,7 +490,6 @@ class XEP_0499: self.handlers: list[RequestHandler] = [] host.register_namespace("pubsub-ext-disco", NS_PUBSUB_EXT_DISCO) - host.register_namespace("pubsub-relationships", NS_PUBSUB_RELATIONSHIPS) host.bridge.add_method( "ps_disco_get", ".plugin", @@ -493,6 +498,95 @@ class XEP_0499: method=self._disco_get, async_=True, ) + self._xep_0496 = cast(XEP_0496, host.plugins["XEP-0496"]) + + async def filter_node_hierarchy_by_access( + self, + client: SatXMPPEntity, + service: JID, + node: DiscoPubsubNode, + entity_jid: JID, + depth: int = 0, + linked_nodes: bool = False, + current_depth: int = 0, + access_cache: dict[str, bool] | None = None, + ) -> DiscoPubsubNode | None: + """Filter a node hierarchy based on access control and depth. + + @param client: Client instance. + @param service: The pubsub service JID. + @param node: The DiscoPubsubNode to filter. + @param entity_jid: The entity JID to check access for. + @param depth: Maximum depth to traverse (0 = only this node). + @param linked_nodes: Whether to include linking nodes. + @param current_depth: Current depth in the traversal. + @param access_cache: Cache of access results for nodes already checked. + @return: Filtered DiscoPubsubNode or None if not accessible. + """ + if access_cache is None: + access_cache = {} + + if node.name in access_cache: + if not access_cache[node.name]: + return None + return DiscoPubsubNode(jid=node.jid, name=node.name, metadata=node.metadata) + + pubsub_node = await G.storage.get_pubsub_node( + client, service, node.name, with_affiliations=True, with_parent=True + ) + if pubsub_node is None: + access_cache[node.name] = False + return None + + has_access = await self._xep_0496.has_access( + client, service, pubsub_node, entity_jid + ) + access_cache[node.name] = has_access + if not has_access: + return None + + filtered_node = DiscoPubsubNode( + jid=node.jid, name=node.name, metadata=node.metadata + ) + + if current_depth < depth: + if node.children is not None: + filtered_children: list[DiscoPubsubNode] = [] + for child in node.children: + filtered_child = await self.filter_node_hierarchy_by_access( + client, + service, + child, + entity_jid, + depth, + linked_nodes, + current_depth + 1, + access_cache, + ) + if filtered_child is not None: + filtered_children.append(filtered_child) + if filtered_children: + filtered_node.children = filtered_children + + if linked_nodes and node.linking_nodes is not None: + filtered_linking: list[DiscoPubsubNode] = [] + for linking in node.linking_nodes: + filtered_linking_node = await self.filter_node_hierarchy_by_access( + client, + service, + linking, + entity_jid, + depth, + linked_nodes, + current_depth + 1, + access_cache, + ) + if filtered_linking_node is not None: + filtered_linking.append(filtered_linking_node) + if filtered_linking: + filtered_node.linking_nodes = filtered_linking + + return filtered_node def get_handler(self, client: SatXMPPEntity) -> "PubsubExtDiscoHandler": return PubsubExtDiscoHandler(self) @@ -564,7 +658,7 @@ class XEP_0499: result_query_elt = iq_result_elt.addElement((disco.NS_DISCO_ITEMS, "query")) if query_elt.hasAttribute("node"): result_query_elt["node"] = query_elt["node"] - for elt in disco_result.to_elements(): + for elt in disco_result.to_elements(ext_disco_options.full_metadata): result_query_elt.addChild(elt) client.send(iq_result_elt)