diff --git a/synapse/handlers/room_summary.py b/synapse/handlers/room_summary.py index 720459f1e7..893961eedb 100644 --- a/synapse/handlers/room_summary.py +++ b/synapse/handlers/room_summary.py @@ -452,11 +452,15 @@ class RoomSummaryHandler: return _RoomEntry(room_id, room_entry) # Otherwise, look for child rooms/spaces. - child_events = await self._get_child_events(room_id) + space_child_events = await self._get_space_child_events(room_id) + # Sort the results for stability. + space_child_events = sorted( + space_child_events, key=_child_events_comparison_key + ) if suggested_only: # we only care about suggested children - child_events = filter(_is_suggested_child_event, child_events) + space_child_events = filter(_is_suggested_child_event, space_child_events) stripped_events: List[JsonDict] = [ { @@ -466,7 +470,7 @@ class RoomSummaryHandler: "sender": e.sender, "origin_server_ts": e.origin_server_ts, } - for e in child_events + for e in space_child_events ] return _RoomEntry(room_id, room_entry, stripped_events) @@ -763,11 +767,9 @@ class RoomSummaryHandler: return room_entry - async def _get_child_events(self, room_id: str) -> Iterable[EventBase]: + async def _get_space_child_events(self, room_id: str) -> Iterable[EventBase]: """ - Get the child events for a given room. - - The returned results are sorted for stability. + Get the space child events for a given room. Args: room_id: The room id to get the children of. @@ -791,7 +793,9 @@ 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) + filtered_events = filter(_has_valid_via, events) + + return filtered_events async def get_room_summary( self, diff --git a/synapse/handlers/sliding_sync.py b/synapse/handlers/sliding_sync.py index cd74218395..4ce32d82f4 100644 --- a/synapse/handlers/sliding_sync.py +++ b/synapse/handlers/sliding_sync.py @@ -184,11 +184,13 @@ class SlidingSyncResult: class SlidingSyncHandler: def __init__(self, hs: "HomeServer"): self.hs_config = hs.config + self.rooms_to_exclude_globally = hs.config.server.rooms_to_exclude_from_sync self.store = hs.get_datastores().main + self.storage_controllers = hs.get_storage_controllers() self.auth_blocking = hs.get_auth_blocking() self.notifier = hs.get_notifier() self.event_sources = hs.get_event_sources() - self.rooms_to_exclude_globally = hs.config.server.rooms_to_exclude_from_sync + self.room_summary_handler = hs.get_room_summary_handler() async def wait_for_sync_for_user( self, @@ -535,8 +537,42 @@ class SlidingSyncHandler: # Only non-DM rooms please filtered_room_id_set = filtered_room_id_set.difference(dm_room_id_set) + # Filter the room based on the space they belong to according to `m.space.child` + # state events. If multiple spaces are present, a room can be part of any one of + # the listed spaces (OR'd). if filters.spaces: - raise NotImplementedError() + # Only use spaces that we're joined to to avoid leaking private space + # information that the user is not part of. We could probably allow + # public spaces here but the spec says "joined" only. + joined_space_room_ids = set() + for space_room_id in set(filters.spaces): + # TODO: Is there a good method to look up all space rooms at once? (N+1 query problem) + is_user_in_room = await self.store.check_local_user_in_room( + user_id=user.to_string(), room_id=space_room_id + ) + + if is_user_in_room: + joined_space_room_ids.add(space_room_id) + + # Flatten the rooms in the spaces + space_child_room_ids = set() + for space_room_id in joined_space_room_ids: + space_child_events = ( + await self.room_summary_handler._get_space_child_events( + space_room_id + ) + ) + space_child_room_ids.update( + event.state_key for event in space_child_events + ) + # TODO: The spec says that if the child room has a `m.room.tombstone` + # event, we should recursively navigate until we find the latest room + # and include those IDs (although this point is under scrutiny). + + # Only rooms in the spaces please + filtered_room_id_set = filtered_room_id_set.intersection( + space_child_room_ids + ) if filters.is_encrypted: raise NotImplementedError() diff --git a/synapse/rest/client/models.py b/synapse/rest/client/models.py index 5433ed91ef..3c75d72464 100644 --- a/synapse/rest/client/models.py +++ b/synapse/rest/client/models.py @@ -238,6 +238,53 @@ class SlidingSyncBody(RequestBodyModel): """ class Filters(RequestBodyModel): + """ + All fields are applied with AND operators, hence if `is_dm: True` and + `is_encrypted: True` then only Encrypted DM rooms will be returned. The absence + of fields implies no filter on that criteria: it does NOT imply `False`. + These fields may be expanded through use of extensions. + + Attributes: + is_dm: Flag which only returns rooms present (or not) in the DM section + of account data. If unset, both DM rooms and non-DM rooms are returned. + If False, only non-DM rooms are returned. If True, only DM rooms are + returned. + spaces: Filter the room based on the space they belong to according to + `m.space.child` state events. If multiple spaces are present, a room can + be part of any one of the listed spaces (OR'd). The server will inspect + the `m.space.child` state events for the JOINED space room IDs given. + Servers MUST NOT navigate subspaces. It is up to the client to give a + complete list of spaces to navigate. Only rooms directly mentioned as + `m.space.child` events in these spaces will be returned. Unknown spaces + or spaces the user is not joined to will be ignored. + is_encrypted: Flag which only returns rooms which have an + `m.room.encryption` state event. If unset, both encrypted and + unencrypted rooms are returned. If `False`, only unencrypted rooms are + returned. If `True`, only encrypted rooms are returned. + is_invite: Flag which only returns rooms the user is currently invited + to. If unset, both invited and joined rooms are returned. If `False`, no + invited rooms are returned. If `True`, only invited rooms are returned. + room_types: If specified, only rooms where the `m.room.create` event has + a `type` matching one of the strings in this array will be returned. If + this field is unset, all rooms are returned regardless of type. This can + be used to get the initial set of spaces for an account. For rooms which + do not have a room type, use `null`/`None` to include them. + not_room_types: Same as `room_types` but inverted. This can be used to + filter out spaces from the room list. If a type is in both `room_types` + and `not_room_types`, then `not_room_types` wins and they are not included + in the result. + room_name_like: Filter the room name. Case-insensitive partial matching + e.g 'foo' matches 'abFooab'. The term 'like' is inspired by SQL 'LIKE', + and the text here is similar to '%foo%'. + tags: Filter the room based on its room tags. If multiple tags are + present, a room can have any one of the listed tags (OR'd). + not_tags: Filter the room based on its room tags. Takes priority over + `tags`. For example, a room with tags A and B with filters `tags: [A]` + `not_tags: [B]` would NOT be included because `not_tags` takes priority over + `tags`. This filter is useful if your rooms list does NOT include the + list of favourite rooms again. + """ + is_dm: Optional[StrictBool] = None spaces: Optional[List[StrictStr]] = None is_encrypted: Optional[StrictBool] = None diff --git a/tests/handlers/test_sliding_sync.py b/tests/handlers/test_sliding_sync.py index 927a11b110..d7bbc0a5a5 100644 --- a/tests/handlers/test_sliding_sync.py +++ b/tests/handlers/test_sliding_sync.py @@ -1,8 +1,15 @@ +from typing import List, Optional import logging from twisted.test.proto_helpers import MemoryReactor -from synapse.api.constants import AccountDataTypes, EventTypes, JoinRules +from synapse.api.constants import ( + AccountDataTypes, + EventContentFields, + EventTypes, + JoinRules, + RoomTypes, +) from synapse.api.room_versions import RoomVersions from synapse.rest import admin from synapse.rest.client import knock, login, room @@ -607,6 +614,32 @@ class FilterRoomsTestCase(HomeserverTestCase): return room_id + def _add_space_child( + self, + space_id: str, + room_id: str, + token: str, + order: Optional[str] = None, + via: Optional[List[str]] = None, + ) -> None: + """ + Helper to add a child room to a space. + """ + + if via is None: + via = [self.hs.hostname] + + content: JsonDict = {"via": via} + if order is not None: + content["order"] = order + self.helper.send_state( + space_id, + event_type=EventTypes.SpaceChild, + body=content, + tok=token, + state_key=room_id, + ) + def test_filter_dm_rooms(self) -> None: """ Test filter for DM rooms @@ -688,3 +721,106 @@ class FilterRoomsTestCase(HomeserverTestCase): ) self.assertEqual(filtered_room_ids, {room_id}) + + def test_filter_space_rooms(self) -> None: + """ + Test filter for non-DM rooms + """ + user1_id = self.register_user("user1", "pass") + user1_tok = self.login(user1_id, "pass") + + space_a = self.helper.create_room_as( + user1_id, + tok=user1_tok, + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + space_b = self.helper.create_room_as( + user1_id, + tok=user1_tok, + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + space_c = self.helper.create_room_as( + user1_id, + tok=user1_tok, + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + + room_id1 = self.helper.create_room_as( + user1_id, + is_public=False, + tok=user1_tok, + ) + # Add to space_a + self._add_space_child(space_a, room_id1, user1_tok) + + room_id2 = self.helper.create_room_as( + user1_id, + is_public=False, + tok=user1_tok, + ) + # Add to space_a and space b + self._add_space_child(space_a, room_id2, user1_tok) + self._add_space_child(space_c, room_id2, user1_tok) + + room_id3 = self.helper.create_room_as( + user1_id, + is_public=False, + tok=user1_tok, + ) + # Add to all spaces + self._add_space_child(space_a, room_id3, user1_tok) + self._add_space_child(space_b, room_id3, user1_tok) + self._add_space_child(space_c, room_id3, user1_tok) + + room_id4 = self.helper.create_room_as( + user1_id, + is_public=False, + tok=user1_tok, + ) + # Add to space_c + self._add_space_child(space_c, room_id3, user1_tok) + + room_not_in_space1 = self.helper.create_room_as( + user1_id, + is_public=False, + tok=user1_tok, + ) + + # TODO: Better way to avoid the circular import? (see + # https://github.com/element-hq/synapse/pull/17187#discussion_r1619492779) + from synapse.handlers.sliding_sync import SlidingSyncConfig + + filters = SlidingSyncConfig.SlidingSyncList.Filters( + spaces=[ + space_a, + space_b, + ], + ) + + # Try filtering the rooms + filtered_room_ids = self.get_success( + self.sliding_sync_handler.filter_rooms( + UserID.from_string(user1_id), + { + # a + room_id1, + # a, c + room_id2, + # a, b, c + room_id3, + # c + room_id4, + # not in any space + room_not_in_space1, + }, + filters, + ) + ) + + self.assertEqual(filtered_room_ids, {room_id1, room_id2, room_id3})