summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/9643.feature1
-rw-r--r--synapse/api/constants.py6
-rw-r--r--synapse/config/experimental.py3
-rw-r--r--synapse/handlers/space_summary.py199
-rw-r--r--synapse/rest/client/v1/room.py66
-rw-r--r--synapse/server.py5
6 files changed, 277 insertions, 3 deletions
diff --git a/changelog.d/9643.feature b/changelog.d/9643.feature
new file mode 100644
index 0000000000..2f7ccedcfb
--- /dev/null
+++ b/changelog.d/9643.feature
@@ -0,0 +1 @@
+Add initial experimental support for a "space summary" API.
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index 691f8f9adf..ed050c8104 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -100,6 +100,9 @@ class EventTypes:
 
     Dummy = "org.matrix.dummy_event"
 
+    MSC1772_SPACE_CHILD = "org.matrix.msc1772.space.child"
+    MSC1772_SPACE_PARENT = "org.matrix.msc1772.space.parent"
+
 
 class EduTypes:
     Presence = "m.presence"
@@ -160,6 +163,9 @@ class EventContentFields:
     # cf https://github.com/matrix-org/matrix-doc/pull/2228
     SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after"
 
+    # cf https://github.com/matrix-org/matrix-doc/pull/1772
+    MSC1772_ROOM_TYPE = "org.matrix.msc1772.type"
+
 
 class RoomEncryptionAlgorithms:
     MEGOLM_V1_AES_SHA2 = "m.megolm.v1.aes-sha2"
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index b1c1c51e4d..5554bcea8a 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -27,3 +27,6 @@ class ExperimentalConfig(Config):
 
         # MSC2858 (multiple SSO identity providers)
         self.msc2858_enabled = experimental.get("msc2858_enabled", False)  # type: bool
+
+        # Spaces (MSC1772, MSC2946, etc)
+        self.spaces_enabled = experimental.get("spaces_enabled", False)  # type: bool
diff --git a/synapse/handlers/space_summary.py b/synapse/handlers/space_summary.py
new file mode 100644
index 0000000000..513dc0c71a
--- /dev/null
+++ b/synapse/handlers/space_summary.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# 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 itertools
+import logging
+from collections import deque
+from typing import TYPE_CHECKING, Iterable, List, Optional, Set
+
+from synapse.api.constants import EventContentFields, EventTypes, HistoryVisibility
+from synapse.api.errors import AuthError
+from synapse.events import EventBase
+from synapse.events.utils import format_event_for_client_v2
+from synapse.types import JsonDict
+
+if TYPE_CHECKING:
+    from synapse.server import HomeServer
+
+logger = logging.getLogger(__name__)
+
+# number of rooms to return. We'll stop once we hit this limit.
+# TODO: allow clients to reduce this with a request param.
+MAX_ROOMS = 50
+
+# max number of events to return per room.
+MAX_ROOMS_PER_SPACE = 50
+
+
+class SpaceSummaryHandler:
+    def __init__(self, hs: "HomeServer"):
+        self._clock = hs.get_clock()
+        self._auth = hs.get_auth()
+        self._room_list_handler = hs.get_room_list_handler()
+        self._state_handler = hs.get_state_handler()
+        self._store = hs.get_datastore()
+        self._event_serializer = hs.get_event_client_serializer()
+
+    async def get_space_summary(
+        self,
+        requester: str,
+        room_id: str,
+        suggested_only: bool = False,
+        max_rooms_per_space: Optional[int] = None,
+    ) -> JsonDict:
+        """
+        Implementation of the space summary API
+
+        Args:
+            requester:  user id of the user making this request
+
+            room_id: room id to start the summary at
+
+            suggested_only: whether we should only return children with the "suggested"
+                flag set.
+
+            max_rooms_per_space: an optional limit on the number of child rooms we will
+                return. This does not apply to the root room (ie, room_id), and
+                is overridden by ROOMS_PER_SPACE_LIMIT.
+
+        Returns:
+            summary dict to return
+        """
+        # first of all, check that the user is in the room in question (or it's
+        # world-readable)
+        await self._auth.check_user_in_room_or_world_readable(room_id, requester)
+
+        # the queue of rooms to process
+        room_queue = deque((room_id,))
+
+        processed_rooms = set()  # type: Set[str]
+
+        rooms_result = []  # type: List[JsonDict]
+        events_result = []  # type: List[JsonDict]
+
+        now = self._clock.time_msec()
+
+        while room_queue and len(rooms_result) < MAX_ROOMS:
+            room_id = room_queue.popleft()
+            logger.debug("Processing room %s", room_id)
+            processed_rooms.add(room_id)
+
+            try:
+                await self._auth.check_user_in_room_or_world_readable(
+                    room_id, requester
+                )
+            except AuthError:
+                logger.info(
+                    "user %s cannot view room %s, omitting from summary",
+                    requester,
+                    room_id,
+                )
+                continue
+
+            room_entry = await self._build_room_entry(room_id)
+            rooms_result.append(room_entry)
+
+            # look for child rooms/spaces.
+            child_events = await self._get_child_events(room_id)
+
+            if suggested_only:
+                # we only care about suggested children
+                child_events = filter(_is_suggested_child_event, child_events)
+
+            # The client-specified max_rooms_per_space limit doesn't apply to the
+            # room_id specified in the request, so we ignore it if this is the
+            # first room we are processing. Otherwise, apply any client-specified
+            # limit, capping to our built-in limit.
+            if max_rooms_per_space is not None and len(processed_rooms) > 1:
+                max_rooms = min(MAX_ROOMS_PER_SPACE, max_rooms_per_space)
+            else:
+                max_rooms = MAX_ROOMS_PER_SPACE
+
+            for edge_event in itertools.islice(child_events, max_rooms):
+                edge_room_id = edge_event.state_key
+
+                events_result.append(
+                    await self._event_serializer.serialize_event(
+                        edge_event,
+                        time_now=now,
+                        event_format=format_event_for_client_v2,
+                    )
+                )
+
+                # if we haven't yet visited the target of this link, add it to the queue
+                if edge_room_id not in processed_rooms:
+                    room_queue.append(edge_room_id)
+
+        return {"rooms": rooms_result, "events": events_result}
+
+    async def _build_room_entry(self, room_id: str) -> JsonDict:
+        """Generate en entry suitable for the 'rooms' list in the summary response"""
+        stats = await self._store.get_room_with_stats(room_id)
+
+        # currently this should be impossible because we call
+        # check_user_in_room_or_world_readable on the room before we get here, so
+        # there should always be an entry
+        assert stats is not None, "unable to retrieve stats for %s" % (room_id,)
+
+        current_state_ids = await self._store.get_current_state_ids(room_id)
+        create_event = await self._store.get_event(
+            current_state_ids[(EventTypes.Create, "")]
+        )
+
+        # TODO: update once MSC1772 lands
+        room_type = create_event.content.get(EventContentFields.MSC1772_ROOM_TYPE)
+
+        entry = {
+            "room_id": stats["room_id"],
+            "name": stats["name"],
+            "topic": stats["topic"],
+            "canonical_alias": stats["canonical_alias"],
+            "num_joined_members": stats["joined_members"],
+            "avatar_url": stats["avatar"],
+            "world_readable": (
+                stats["history_visibility"] == HistoryVisibility.WORLD_READABLE
+            ),
+            "guest_can_join": stats["guest_access"] == "can_join",
+            "room_type": room_type,
+        }
+
+        # Filter out Nones – rather omit the field altogether
+        room_entry = {k: v for k, v in entry.items() if v is not None}
+
+        return room_entry
+
+    async def _get_child_events(self, room_id: str) -> Iterable[EventBase]:
+        # look for child rooms/spaces.
+        current_state_ids = await self._store.get_current_state_ids(room_id)
+
+        events = await self._store.get_events_as_list(
+            [
+                event_id
+                for key, event_id in current_state_ids.items()
+                # TODO: update once MSC1772 lands
+                if key[0] == EventTypes.MSC1772_SPACE_CHILD
+            ]
+        )
+
+        # filter out any events without a "via" (which implies it has been redacted)
+        return (e for e in events if e.content.get("via"))
+
+
+def _is_suggested_child_event(edge_event: EventBase) -> bool:
+    suggested = edge_event.content.get("suggested")
+    if isinstance(suggested, bool) and suggested:
+        return True
+    logger.debug("Ignorning not-suggested child %s", edge_event.state_key)
+    return False
diff --git a/synapse/rest/client/v1/room.py b/synapse/rest/client/v1/room.py
index e7a8207eb1..f78d792264 100644
--- a/synapse/rest/client/v1/room.py
+++ b/synapse/rest/client/v1/room.py
@@ -18,9 +18,11 @@
 
 import logging
 import re
-from typing import TYPE_CHECKING, List, Optional
+from typing import TYPE_CHECKING, List, Optional, Tuple
 from urllib import parse as urlparse
 
+from twisted.web.server import Request
+
 from synapse.api.constants import EventTypes, Membership
 from synapse.api.errors import (
     AuthError,
@@ -35,6 +37,7 @@ from synapse.events.utils import format_event_for_client_v2
 from synapse.http.servlet import (
     RestServlet,
     assert_params_in_dict,
+    parse_boolean,
     parse_integer,
     parse_json_object_from_request,
     parse_string,
@@ -44,7 +47,14 @@ from synapse.rest.client.transactions import HttpTransactionCache
 from synapse.rest.client.v2_alpha._base import client_patterns
 from synapse.storage.state import StateFilter
 from synapse.streams.config import PaginationConfig
-from synapse.types import RoomAlias, RoomID, StreamToken, ThirdPartyInstanceID, UserID
+from synapse.types import (
+    JsonDict,
+    RoomAlias,
+    RoomID,
+    StreamToken,
+    ThirdPartyInstanceID,
+    UserID,
+)
 from synapse.util import json_decoder
 from synapse.util.stringutils import parse_and_validate_server_name, random_string
 
@@ -987,7 +997,54 @@ def register_txn_path(servlet, regex_string, http_server, with_get=False):
         )
 
 
-def register_servlets(hs, http_server, is_worker=False):
+class RoomSpaceSummaryRestServlet(RestServlet):
+    PATTERNS = (
+        re.compile(
+            "^/_matrix/client/unstable/org.matrix.msc2946"
+            "/rooms/(?P<room_id>[^/]*)/spaces$"
+        ),
+    )
+
+    def __init__(self, hs: "synapse.server.HomeServer"):
+        super().__init__()
+        self._auth = hs.get_auth()
+        self._space_summary_handler = hs.get_space_summary_handler()
+
+    async def on_GET(self, request: Request, room_id: str) -> Tuple[int, JsonDict]:
+        requester = await self._auth.get_user_by_req(request, allow_guest=True)
+
+        return 200, await self._space_summary_handler.get_space_summary(
+            requester.user.to_string(),
+            room_id,
+            suggested_only=parse_boolean(request, "suggested_only", default=False),
+            max_rooms_per_space=parse_integer(request, "max_rooms_per_space"),
+        )
+
+    async def on_POST(self, request: Request, room_id: str) -> Tuple[int, JsonDict]:
+        requester = await self._auth.get_user_by_req(request, allow_guest=True)
+        content = parse_json_object_from_request(request)
+
+        suggested_only = content.get("suggested_only", False)
+        if not isinstance(suggested_only, bool):
+            raise SynapseError(
+                400, "'suggested_only' must be a boolean", Codes.BAD_JSON
+            )
+
+        max_rooms_per_space = content.get("max_rooms_per_space")
+        if max_rooms_per_space is not None and not isinstance(max_rooms_per_space, int):
+            raise SynapseError(
+                400, "'max_rooms_per_space' must be an integer", Codes.BAD_JSON
+            )
+
+        return 200, await self._space_summary_handler.get_space_summary(
+            requester.user.to_string(),
+            room_id,
+            suggested_only=suggested_only,
+            max_rooms_per_space=max_rooms_per_space,
+        )
+
+
+def register_servlets(hs: "synapse.server.HomeServer", http_server, is_worker=False):
     RoomStateEventRestServlet(hs).register(http_server)
     RoomMemberListRestServlet(hs).register(http_server)
     JoinedRoomMemberListRestServlet(hs).register(http_server)
@@ -1001,6 +1058,9 @@ def register_servlets(hs, http_server, is_worker=False):
     RoomTypingRestServlet(hs).register(http_server)
     RoomEventContextServlet(hs).register(http_server)
 
+    if hs.config.experimental.spaces_enabled:
+        RoomSpaceSummaryRestServlet(hs).register(http_server)
+
     # Some servlets only get registered for the main process.
     if not is_worker:
         RoomCreateRestServlet(hs).register(http_server)
diff --git a/synapse/server.py b/synapse/server.py
index d11d08c573..98822d8e2f 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -100,6 +100,7 @@ from synapse.handlers.room_member import RoomMemberHandler, RoomMemberMasterHand
 from synapse.handlers.room_member_worker import RoomMemberWorkerHandler
 from synapse.handlers.search import SearchHandler
 from synapse.handlers.set_password import SetPasswordHandler
+from synapse.handlers.space_summary import SpaceSummaryHandler
 from synapse.handlers.sso import SsoHandler
 from synapse.handlers.stats import StatsHandler
 from synapse.handlers.sync import SyncHandler
@@ -733,6 +734,10 @@ class HomeServer(metaclass=abc.ABCMeta):
         return AccountDataHandler(self)
 
     @cache_in_self
+    def get_space_summary_handler(self) -> SpaceSummaryHandler:
+        return SpaceSummaryHandler(self)
+
+    @cache_in_self
     def get_external_cache(self) -> ExternalCache:
         return ExternalCache(self)