summary refs log tree commit diff
diff options
context:
space:
mode:
authorSean Quah <seanq@element.io>2021-11-16 13:28:47 +0000
committerSean Quah <seanq@element.io>2021-11-16 13:45:03 +0000
commit98873d7be301d7488c4706142513181aa309f496 (patch)
tree61f8efd13d07abb6d6cbee25007bad80a23f5969
parentAnnotate string constants in `synapse.api.constants` with `Final` (diff)
downloadsynapse-98873d7be301d7488c4706142513181aa309f496.tar.xz
Add `RoomHierarchyHandler` for walking space hierarchies
-rw-r--r--synapse/handlers/room_hierarchy.py274
-rw-r--r--synapse/handlers/room_summary.py6
-rw-r--r--synapse/server.py5
-rw-r--r--tests/handlers/test_room_summary.py4
4 files changed, 284 insertions, 5 deletions
diff --git a/synapse/handlers/room_hierarchy.py b/synapse/handlers/room_hierarchy.py
new file mode 100644
index 0000000000..54cdc130bd
--- /dev/null
+++ b/synapse/handlers/room_hierarchy.py
@@ -0,0 +1,274 @@
+# Copyright 2021 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+from typing import (
+    TYPE_CHECKING,
+    Dict,
+    Iterable,
+    List,
+    Mapping,
+    Optional,
+    Sequence,
+    Tuple,
+)
+
+from synapse.api.constants import EventContentFields, EventTypes, RoomTypes
+from synapse.api.errors import SynapseError
+from synapse.handlers.room_summary import child_events_comparison_key, has_valid_via
+from synapse.storage.state import StateFilter
+from synapse.types import JsonDict
+
+if TYPE_CHECKING:
+    from synapse.server import HomeServer
+
+
+logger = logging.getLogger(__name__)
+
+
+class RoomHierarchyHandler:
+    """Provides methods for walking over space hierarchies.
+
+    Also see `RoomSummaryHandler`, which has similar functionality.
+    """
+
+    def __init__(self, hs: "HomeServer"):
+        self._store = hs.get_datastore()
+        self._federation_client = hs.get_federation_client()
+
+        self._server_name = hs.hostname
+
+    async def get_room_descendants(
+        self, space_id: str, via: Optional[Iterable[str]] = None
+    ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]:
+        """Gets the children of a space, recursively.
+
+        Args:
+            space_id: The room ID of the space.
+
+        Returns:
+            A tuple containing:
+             * A list of (room ID, via) tuples, representing the descendants of the
+               space. `space_id` is included in the list.
+             * A list of room IDs whose children could not be fully listed.
+               Rooms in this list are either spaces not known locally, and thus require
+               listing over federation, or are unknown rooms or subspaces completely
+               inaccessible to the local homeserver which may contain further rooms.
+
+               This list is a subset of the previous list, except it may include
+               `space_id`.
+        """
+        via = via or []
+
+        # (room ID, via, federation room chunks)
+        todo: List[Tuple[str, Iterable[str], Mapping[str, Optional[JsonDict]]]] = [
+            (space_id, via, {})
+        ]
+        descendants: List[Tuple[str, Iterable[str]]] = []
+
+        seen = {space_id}
+
+        inaccessible_room_ids: List[str] = []
+
+        while todo:
+            space_id, via, federation_room_chunks = todo.pop()
+            descendants.append((space_id, via))
+            try:
+                (
+                    is_in_room,
+                    children,
+                    federation_room_chunks,
+                ) = await self._get_room_children(space_id, via, federation_room_chunks)
+            except SynapseError:
+                # Could not list children over federation
+                inaccessible_room_ids.append(space_id)
+                continue
+
+            for child_room_id, child_via in reversed(children):
+                if child_room_id in seen:
+                    continue
+
+                seen.add(child_room_id)
+
+                # Queue up the child for processing.
+                # The child may not actually be a space, but that's checked by
+                # `_get_room_children`.
+                todo.append((child_room_id, child_via, federation_room_chunks))
+
+            # Children were retrieved over federation, which is not guaranteed to be
+            # the full list.
+            if not is_in_room:
+                inaccessible_room_ids.append(space_id)
+
+        return descendants, inaccessible_room_ids
+
+    async def _get_room_children(
+        self,
+        space_id: str,
+        via: Optional[Iterable[str]] = None,
+        federation_room_chunks: Optional[Mapping[str, Optional[JsonDict]]] = None,
+    ) -> Tuple[
+        bool, Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]
+    ]:
+        """Gets the direct children of a space.
+
+        Args:
+            space_id: The room ID of the space.
+            via: A list of servers which may know about the space.
+            federation_room_chunks: A cache of room chunks previously returned by
+               `_get_room_children` that may be used to skip federation requests for
+               inaccessible or non-space rooms.
+
+        Returns:
+            A tuple containing:
+             * A boolean indicating whether `space_id` is known to the local homeserver.
+             * A list of (room ID, via) tuples, representing the children of the space,
+               if `space_id` refers to a space; an empty list otherwise.
+             * A dictionary of child room ID: `PublicRoomsChunk`s returned over
+               federation:
+               https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms
+               These are supposed to include extra `room_type` and `allowed_room_ids`
+               fields, as described in MSC2946.
+
+               Contains `None` for rooms to which the remote homeserver thinks we do not
+               have access.
+
+               Local information about rooms should be trusted over data in this
+               dictionary.
+
+        Raises:
+            SynapseError: if `space_id` is not known locally and its children could not
+                be retrieved over federation.
+        """
+        via = via or []
+        federation_room_chunks = federation_room_chunks or {}
+
+        is_in_room = await self._store.is_host_joined(space_id, self._server_name)
+        if is_in_room:
+            children = await self._get_room_children_local(space_id)
+            return True, children, {}
+        else:
+            # Check the room chunks previously returned over federation to see if we
+            # should really make a request.
+            # `federation_room_chunks` is intentionally not used earlier since we want
+            # to trust local data over data from federation.
+            if space_id in federation_room_chunks:
+                room_chunk = federation_room_chunks[space_id]
+                if room_chunk is None:
+                    # `space_id` is inaccessible to the local homeserver according to
+                    # federation.
+                    raise SynapseError(
+                        502, f"{space_id} is not accessible to the local homeserver"
+                    )
+                elif room_chunk.get("room_type") != RoomTypes.SPACE:
+                    # `space_id` is not a space according to federation.
+                    return False, [], {}
+
+            children, room_chunks = await self._get_room_children_remote(space_id, via)
+            return False, children, room_chunks
+
+    async def _get_room_children_local(
+        self, space_id: str
+    ) -> Sequence[Tuple[str, Iterable[str]]]:
+        """Gets the direct children of a space that the local homeserver is in.
+
+        Args:
+            space_id: The room ID of the space.
+
+        Returns:
+            A list of (room ID, via) tuples, representing the children of the space,
+            if `space_id` refers to a space; an empty list otherwise.
+
+        Raises:
+            ValueError: if `space_id` is not known locally.
+        """
+        # Fetch the `m.room.create` and `m.space.child` events for `space_id`
+        state_filter = StateFilter.from_types(
+            [(EventTypes.Create, ""), (EventTypes.SpaceChild, None)]
+        )
+        current_state_ids = await self._store.get_filtered_current_state_ids(
+            space_id, state_filter
+        )
+        state_events = await self._store.get_events_as_list(current_state_ids.values())
+        assert len(state_events) == len(current_state_ids)
+
+        create_event_id = current_state_ids.get((EventTypes.Create, ""))
+        if create_event_id is None:
+            # The local homeserver is not in this room
+            raise ValueError(f"{space_id} is not a room known locally.")
+
+        create_event = next(
+            event for event in state_events if event.event_id == create_event_id
+        )
+        if create_event.content.get(EventContentFields.ROOM_TYPE) != RoomTypes.SPACE:
+            # `space_id` is a regular room and not a space.
+            # Ignore any `m.space.child` events.
+            return []
+
+        child_events = [
+            event
+            for event in state_events
+            # Ignore events with a missing or non-array `via`, as per MSC1772
+            if event.event_id != create_event_id and has_valid_via(event)
+        ]
+        child_events.sort(key=child_events_comparison_key)
+        return [(event.state_key, event.content["via"]) for event in child_events]
+
+    async def _get_room_children_remote(
+        self, space_id: str, via: Iterable[str]
+    ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]]:
+        """Gets the direct children of a space over federation.
+
+        Args:
+            space_id: The room ID of the space.
+            via: A list of servers which may know about the space.
+
+        Returns:
+            A tuple containing:
+             * A list of (room ID, via) tuples, representing the children of the space,
+               if `space_id` refers to a space; an empty list otherwise.
+             * A dictionary of child room ID: `PublicRoomsChunk`s returned over
+               federation:
+               https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms
+               These are supposed to include extra `room_type` and `allowed_room_ids`
+               fields, as described in MSC2946.
+
+               Contains `None` for rooms to which the remote homeserver thinks we do not
+               have access.
+
+        Raises:
+            SynapseError: if none of the remote servers provided us with the space's
+                children.
+        """
+        (
+            room,
+            children_chunks,
+            inaccessible_children,
+        ) = await self._federation_client.get_room_hierarchy(
+            via, space_id, suggested_only=False
+        )
+
+        child_events: List[JsonDict] = room["children_state"]
+        children = [
+            (child_event["room_id"], child_event["content"]["via"])
+            for child_event in child_events
+        ]
+
+        room_chunks: Dict[str, Optional[JsonDict]] = {}
+        room_chunks.update((room_id, None) for room_id in inaccessible_children)
+        room_chunks.update(
+            (room_chunk["room_id"], room_chunk) for room_chunk in children_chunks
+        )
+
+        return children, room_chunks
diff --git a/synapse/handlers/room_summary.py b/synapse/handlers/room_summary.py
index fb26ee7ad7..d9764a7797 100644
--- a/synapse/handlers/room_summary.py
+++ b/synapse/handlers/room_summary.py
@@ -1032,7 +1032,7 @@ class RoomSummaryHandler:
 
         # filter out any events without a "via" (which implies it has been redacted),
         # and order to ensure we return stable results.
-        return sorted(filter(_has_valid_via, events), key=_child_events_comparison_key)
+        return sorted(filter(has_valid_via, events), key=child_events_comparison_key)
 
     async def get_room_summary(
         self,
@@ -1126,7 +1126,7 @@ class _RoomEntry:
         return result
 
 
-def _has_valid_via(e: EventBase) -> bool:
+def has_valid_via(e: EventBase) -> bool:
     via = e.content.get("via")
     if not via or not isinstance(via, Sequence):
         return False
@@ -1149,7 +1149,7 @@ def _is_suggested_child_event(edge_event: EventBase) -> bool:
 _INVALID_ORDER_CHARS_RE = re.compile(r"[^\x20-\x7E]")
 
 
-def _child_events_comparison_key(
+def child_events_comparison_key(
     child: EventBase,
 ) -> Tuple[bool, Optional[str], int, str]:
     """
diff --git a/synapse/server.py b/synapse/server.py
index 877eba6c08..cd8f260c70 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -99,6 +99,7 @@ from synapse.handlers.room import (
     RoomShutdownHandler,
 )
 from synapse.handlers.room_batch import RoomBatchHandler
+from synapse.handlers.room_hierarchy import RoomHierarchyHandler
 from synapse.handlers.room_list import RoomListHandler
 from synapse.handlers.room_member import RoomMemberHandler, RoomMemberMasterHandler
 from synapse.handlers.room_member_worker import RoomMemberWorkerHandler
@@ -791,6 +792,10 @@ class HomeServer(metaclass=abc.ABCMeta):
         return AccountDataHandler(self)
 
     @cache_in_self
+    def get_room_hierarchy_handler(self) -> RoomHierarchyHandler:
+        return RoomHierarchyHandler(self)
+
+    @cache_in_self
     def get_room_summary_handler(self) -> RoomSummaryHandler:
         return RoomSummaryHandler(self)
 
diff --git a/tests/handlers/test_room_summary.py b/tests/handlers/test_room_summary.py
index d3d0bf1ac5..86beb8ff08 100644
--- a/tests/handlers/test_room_summary.py
+++ b/tests/handlers/test_room_summary.py
@@ -26,7 +26,7 @@ from synapse.api.constants import (
 from synapse.api.errors import AuthError, NotFoundError, SynapseError
 from synapse.api.room_versions import RoomVersions
 from synapse.events import make_event_from_dict
-from synapse.handlers.room_summary import _child_events_comparison_key, _RoomEntry
+from synapse.handlers.room_summary import _RoomEntry, child_events_comparison_key
 from synapse.rest import admin
 from synapse.rest.client import login, room
 from synapse.server import HomeServer
@@ -46,7 +46,7 @@ def _create_event(room_id: str, order: Optional[Any] = None, origin_server_ts: i
 
 
 def _order(*events):
-    return sorted(events, key=_child_events_comparison_key)
+    return sorted(events, key=child_events_comparison_key)
 
 
 class TestSpaceSummarySort(unittest.TestCase):