Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Initial spaces summary API #9643

Merged
merged 11 commits into from
Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9643.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add initial experimental support for a "space summary" API.
6 changes: 6 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ def read_config(self, config: JsonDict, **kwargs):

# 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
199 changes: 199 additions & 0 deletions synapse/handlers/space_summary.py
Original file line number Diff line number Diff line change
@@ -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)
clokep marked this conversation as resolved.
Show resolved Hide resolved

# 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)
Comment on lines +155 to +156
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was the reason for the second config option: I wanted its presence to remind us that there was this "unstable" code in here, beyond the experimentalness of the SpaceSummaryHandler as a whole. Anyway, I've replaced the conditions with TODOs.


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]:
clokep marked this conversation as resolved.
Show resolved Hide resolved
# look for child rooms/spaces.
current_state_ids = await self._store.get_current_state_ids(room_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like there should be an easier way to pull the state of a room filtering for the event type (instead of having to do it after the fact). It seems we only have methods for filtering by the type + state key though, which doesn't help here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree there should, but it's not a can of worms I want to open right this minute.


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
66 changes: 63 additions & 3 deletions synapse/rest/client/v1/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,6 +37,7 @@
from synapse.http.servlet import (
RestServlet,
assert_params_in_dict,
parse_boolean,
parse_integer,
parse_json_object_from_request,
parse_string,
Expand All @@ -44,7 +47,14 @@
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

Expand Down Expand Up @@ -981,7 +991,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)
Expand All @@ -995,6 +1052,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)
Expand Down
5 changes: 5 additions & 0 deletions synapse/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
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
Expand Down Expand Up @@ -725,6 +726,10 @@ def get_module_api(self) -> ModuleApi:
def get_account_data_handler(self) -> AccountDataHandler:
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)
Expand Down