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

Commit 4ecba9b

Browse files
authored
Federation API for Space summary (#9652)
Builds on the work done in #9643 to add a federation API for space summaries. There's a bit of refactoring of the existing client-server code first, to avoid too much duplication.
1 parent b7748d3 commit 4ecba9b

File tree

3 files changed

+197
-54
lines changed

3 files changed

+197
-54
lines changed

changelog.d/9652.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add initial experimental support for a "space summary" API.

synapse/federation/transport/server.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import functools
1919
import logging
2020
import re
21-
from typing import Optional, Tuple, Type
21+
from typing import Container, Mapping, Optional, Sequence, Tuple, Type
2222

2323
import synapse
2424
from synapse.api.constants import MAX_GROUP_CATEGORYID_LENGTH, MAX_GROUP_ROLEID_LENGTH
@@ -29,7 +29,7 @@
2929
FEDERATION_V1_PREFIX,
3030
FEDERATION_V2_PREFIX,
3131
)
32-
from synapse.http.server import JsonResource
32+
from synapse.http.server import HttpServer, JsonResource
3333
from synapse.http.servlet import (
3434
parse_boolean_from_args,
3535
parse_integer_from_args,
@@ -44,7 +44,8 @@
4444
whitelisted_homeserver,
4545
)
4646
from synapse.server import HomeServer
47-
from synapse.types import ThirdPartyInstanceID, get_domain_from_id
47+
from synapse.types import JsonDict, ThirdPartyInstanceID, get_domain_from_id
48+
from synapse.util.ratelimitutils import FederationRateLimiter
4849
from synapse.util.stringutils import parse_and_validate_server_name
4950
from synapse.util.versionstring import get_version_string
5051

@@ -1376,6 +1377,40 @@ async def on_PUT(self, origin, content, query, group_id):
13761377
return 200, new_content
13771378

13781379

1380+
class FederationSpaceSummaryServlet(BaseFederationServlet):
1381+
PREFIX = FEDERATION_UNSTABLE_PREFIX + "/org.matrix.msc2946"
1382+
PATH = "/spaces/(?P<room_id>[^/]*)"
1383+
1384+
async def on_POST(
1385+
self,
1386+
origin: str,
1387+
content: JsonDict,
1388+
query: Mapping[bytes, Sequence[bytes]],
1389+
room_id: str,
1390+
) -> Tuple[int, JsonDict]:
1391+
suggested_only = content.get("suggested_only", False)
1392+
if not isinstance(suggested_only, bool):
1393+
raise SynapseError(
1394+
400, "'suggested_only' must be a boolean", Codes.BAD_JSON
1395+
)
1396+
1397+
exclude_rooms = content.get("exclude_rooms", [])
1398+
if not isinstance(exclude_rooms, list) or any(
1399+
not isinstance(x, str) for x in exclude_rooms
1400+
):
1401+
raise SynapseError(400, "bad value for 'exclude_rooms'", Codes.BAD_JSON)
1402+
1403+
max_rooms_per_space = content.get("max_rooms_per_space")
1404+
if max_rooms_per_space is not None and not isinstance(max_rooms_per_space, int):
1405+
raise SynapseError(
1406+
400, "bad value for 'max_rooms_per_space'", Codes.BAD_JSON
1407+
)
1408+
1409+
return 200, await self.handler.federation_space_summary(
1410+
room_id, suggested_only, max_rooms_per_space, exclude_rooms
1411+
)
1412+
1413+
13791414
class RoomComplexityServlet(BaseFederationServlet):
13801415
"""
13811416
Indicates to other servers how complex (and therefore likely
@@ -1474,18 +1509,24 @@ async def on_GET(self, origin, content, query, room_id):
14741509
)
14751510

14761511

1477-
def register_servlets(hs, resource, authenticator, ratelimiter, servlet_groups=None):
1512+
def register_servlets(
1513+
hs: HomeServer,
1514+
resource: HttpServer,
1515+
authenticator: Authenticator,
1516+
ratelimiter: FederationRateLimiter,
1517+
servlet_groups: Optional[Container[str]] = None,
1518+
):
14781519
"""Initialize and register servlet classes.
14791520
14801521
Will by default register all servlets. For custom behaviour, pass in
14811522
a list of servlet_groups to register.
14821523
14831524
Args:
1484-
hs (synapse.server.HomeServer): homeserver
1485-
resource (JsonResource): resource class to register to
1486-
authenticator (Authenticator): authenticator to use
1487-
ratelimiter (util.ratelimitutils.FederationRateLimiter): ratelimiter to use
1488-
servlet_groups (list[str], optional): List of servlet groups to register.
1525+
hs: homeserver
1526+
resource: resource class to register to
1527+
authenticator: authenticator to use
1528+
ratelimiter: ratelimiter to use
1529+
servlet_groups: List of servlet groups to register.
14891530
Defaults to ``DEFAULT_SERVLET_GROUPS``.
14901531
"""
14911532
if not servlet_groups:
@@ -1500,6 +1541,14 @@ def register_servlets(hs, resource, authenticator, ratelimiter, servlet_groups=N
15001541
server_name=hs.hostname,
15011542
).register(resource)
15021543

1544+
if hs.config.experimental.spaces_enabled:
1545+
FederationSpaceSummaryServlet(
1546+
handler=hs.get_space_summary_handler(),
1547+
authenticator=authenticator,
1548+
ratelimiter=ratelimiter,
1549+
server_name=hs.hostname,
1550+
).register(resource)
1551+
15031552
if "openid" in servlet_groups:
15041553
for servletclass in OPENID_SERVLET_CLASSES:
15051554
servletclass(

synapse/handlers/space_summary.py

Lines changed: 138 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
import itertools
1717
import logging
1818
from collections import deque
19-
from typing import TYPE_CHECKING, Iterable, List, Optional, Set
19+
from typing import TYPE_CHECKING, Iterable, List, Optional, Sequence, Set, Tuple
20+
21+
import attr
2022

2123
from synapse.api.constants import EventContentFields, EventTypes, HistoryVisibility
2224
from synapse.api.errors import AuthError
@@ -54,7 +56,7 @@ async def get_space_summary(
5456
max_rooms_per_space: Optional[int] = None,
5557
) -> JsonDict:
5658
"""
57-
Implementation of the space summary API
59+
Implementation of the space summary C-S API
5860
5961
Args:
6062
requester: user id of the user making this request
@@ -66,7 +68,7 @@ async def get_space_summary(
6668
6769
max_rooms_per_space: an optional limit on the number of child rooms we will
6870
return. This does not apply to the root room (ie, room_id), and
69-
is overridden by ROOMS_PER_SPACE_LIMIT.
71+
is overridden by MAX_ROOMS_PER_SPACE.
7072
7173
Returns:
7274
summary dict to return
@@ -76,67 +78,153 @@ async def get_space_summary(
7678
await self._auth.check_user_in_room_or_world_readable(room_id, requester)
7779

7880
# the queue of rooms to process
79-
room_queue = deque((room_id,))
81+
room_queue = deque((_RoomQueueEntry(room_id),))
8082

8183
processed_rooms = set() # type: Set[str]
8284

8385
rooms_result = [] # type: List[JsonDict]
8486
events_result = [] # type: List[JsonDict]
8587

86-
now = self._clock.time_msec()
88+
while room_queue and len(rooms_result) < MAX_ROOMS:
89+
queue_entry = room_queue.popleft()
90+
room_id = queue_entry.room_id
91+
logger.debug("Processing room %s", room_id)
92+
processed_rooms.add(room_id)
93+
94+
# The client-specified max_rooms_per_space limit doesn't apply to the
95+
# room_id specified in the request, so we ignore it if this is the
96+
# first room we are processing.
97+
max_children = max_rooms_per_space if processed_rooms else None
98+
99+
rooms, events = await self._summarize_local_room(
100+
requester, room_id, suggested_only, max_children
101+
)
102+
103+
rooms_result.extend(rooms)
104+
events_result.extend(events)
105+
106+
# add any children that we haven't already processed to the queue
107+
for edge_event in events:
108+
if edge_event["state_key"] not in processed_rooms:
109+
room_queue.append(_RoomQueueEntry(edge_event["state_key"]))
110+
111+
return {"rooms": rooms_result, "events": events_result}
112+
113+
async def federation_space_summary(
114+
self,
115+
room_id: str,
116+
suggested_only: bool,
117+
max_rooms_per_space: Optional[int],
118+
exclude_rooms: Iterable[str],
119+
) -> JsonDict:
120+
"""
121+
Implementation of the space summary Federation API
122+
123+
Args:
124+
room_id: room id to start the summary at
125+
126+
suggested_only: whether we should only return children with the "suggested"
127+
flag set.
128+
129+
max_rooms_per_space: an optional limit on the number of child rooms we will
130+
return. Unlike the C-S API, this applies to the root room (room_id).
131+
It is clipped to MAX_ROOMS_PER_SPACE.
132+
133+
exclude_rooms: a list of rooms to skip over (presumably because the
134+
calling server has already seen them).
135+
136+
Returns:
137+
summary dict to return
138+
"""
139+
# the queue of rooms to process
140+
room_queue = deque((room_id,))
141+
142+
# the set of rooms that we should not walk further. Initialise it with the
143+
# excluded-rooms list; we will add other rooms as we process them so that
144+
# we do not loop.
145+
processed_rooms = set(exclude_rooms) # type: Set[str]
146+
147+
rooms_result = [] # type: List[JsonDict]
148+
events_result = [] # type: List[JsonDict]
87149

88150
while room_queue and len(rooms_result) < MAX_ROOMS:
89151
room_id = room_queue.popleft()
90152
logger.debug("Processing room %s", room_id)
91153
processed_rooms.add(room_id)
92154

93-
try:
94-
await self._auth.check_user_in_room_or_world_readable(
95-
room_id, requester
96-
)
97-
except AuthError:
98-
logger.info(
99-
"user %s cannot view room %s, omitting from summary",
100-
requester,
101-
room_id,
102-
)
103-
continue
155+
rooms, events = await self._summarize_local_room(
156+
None, room_id, suggested_only, max_rooms_per_space
157+
)
104158

105-
room_entry = await self._build_room_entry(room_id)
106-
rooms_result.append(room_entry)
159+
rooms_result.extend(rooms)
160+
events_result.extend(events)
107161

108-
# look for child rooms/spaces.
109-
child_events = await self._get_child_events(room_id)
162+
# add any children that we haven't already processed to the queue
163+
for edge_event in events:
164+
if edge_event["state_key"] not in processed_rooms:
165+
room_queue.append(edge_event["state_key"])
110166

111-
if suggested_only:
112-
# we only care about suggested children
113-
child_events = filter(_is_suggested_child_event, child_events)
167+
return {"rooms": rooms_result, "events": events_result}
114168

115-
# The client-specified max_rooms_per_space limit doesn't apply to the
116-
# room_id specified in the request, so we ignore it if this is the
117-
# first room we are processing. Otherwise, apply any client-specified
118-
# limit, capping to our built-in limit.
119-
if max_rooms_per_space is not None and len(processed_rooms) > 1:
120-
max_rooms = min(MAX_ROOMS_PER_SPACE, max_rooms_per_space)
121-
else:
122-
max_rooms = MAX_ROOMS_PER_SPACE
123-
124-
for edge_event in itertools.islice(child_events, max_rooms):
125-
edge_room_id = edge_event.state_key
126-
127-
events_result.append(
128-
await self._event_serializer.serialize_event(
129-
edge_event,
130-
time_now=now,
131-
event_format=format_event_for_client_v2,
132-
)
169+
async def _summarize_local_room(
170+
self,
171+
requester: Optional[str],
172+
room_id: str,
173+
suggested_only: bool,
174+
max_children: Optional[int],
175+
) -> Tuple[Sequence[JsonDict], Sequence[JsonDict]]:
176+
if not await self._is_room_accessible(room_id, requester):
177+
return (), ()
178+
179+
room_entry = await self._build_room_entry(room_id)
180+
181+
# look for child rooms/spaces.
182+
child_events = await self._get_child_events(room_id)
183+
184+
if suggested_only:
185+
# we only care about suggested children
186+
child_events = filter(_is_suggested_child_event, child_events)
187+
188+
if max_children is None or max_children > MAX_ROOMS_PER_SPACE:
189+
max_children = MAX_ROOMS_PER_SPACE
190+
191+
now = self._clock.time_msec()
192+
events_result = [] # type: List[JsonDict]
193+
for edge_event in itertools.islice(child_events, max_children):
194+
events_result.append(
195+
await self._event_serializer.serialize_event(
196+
edge_event,
197+
time_now=now,
198+
event_format=format_event_for_client_v2,
133199
)
200+
)
201+
return (room_entry,), events_result
134202

135-
# if we haven't yet visited the target of this link, add it to the queue
136-
if edge_room_id not in processed_rooms:
137-
room_queue.append(edge_room_id)
203+
async def _is_room_accessible(self, room_id: str, requester: Optional[str]) -> bool:
204+
# if we have an authenticated requesting user, first check if they are in the
205+
# room
206+
if requester:
207+
try:
208+
await self._auth.check_user_in_room(room_id, requester)
209+
return True
210+
except AuthError:
211+
pass
138212

139-
return {"rooms": rooms_result, "events": events_result}
213+
# otherwise, check if the room is peekable
214+
hist_vis_ev = await self._state_handler.get_current_state(
215+
room_id, EventTypes.RoomHistoryVisibility, ""
216+
)
217+
if hist_vis_ev:
218+
hist_vis = hist_vis_ev.content.get("history_visibility")
219+
if hist_vis == HistoryVisibility.WORLD_READABLE:
220+
return True
221+
222+
logger.info(
223+
"room %s is unpeekable and user %s is not a member, omitting from summary",
224+
room_id,
225+
requester,
226+
)
227+
return False
140228

141229
async def _build_room_entry(self, room_id: str) -> JsonDict:
142230
"""Generate en entry suitable for the 'rooms' list in the summary response"""
@@ -191,6 +279,11 @@ async def _get_child_events(self, room_id: str) -> Iterable[EventBase]:
191279
return (e for e in events if e.content.get("via"))
192280

193281

282+
@attr.s(frozen=True, slots=True)
283+
class _RoomQueueEntry:
284+
room_id = attr.ib(type=str)
285+
286+
194287
def _is_suggested_child_event(edge_event: EventBase) -> bool:
195288
suggested = edge_event.content.get("suggested")
196289
if isinstance(suggested, bool) and suggested:

0 commit comments

Comments
 (0)