You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

972 line
36 KiB

  1. # Copyright 2021 The Matrix.org Foundation C.I.C.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import itertools
  15. import logging
  16. import re
  17. from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Set, Tuple
  18. import attr
  19. from synapse.api.constants import (
  20. EventTypes,
  21. HistoryVisibility,
  22. JoinRules,
  23. Membership,
  24. RoomTypes,
  25. )
  26. from synapse.api.errors import (
  27. Codes,
  28. NotFoundError,
  29. StoreError,
  30. SynapseError,
  31. UnstableSpecAuthError,
  32. UnsupportedRoomVersionError,
  33. )
  34. from synapse.api.ratelimiting import Ratelimiter
  35. from synapse.config.ratelimiting import RatelimitSettings
  36. from synapse.events import EventBase
  37. from synapse.types import JsonDict, Requester, StrCollection
  38. from synapse.util.caches.response_cache import ResponseCache
  39. if TYPE_CHECKING:
  40. from synapse.server import HomeServer
  41. logger = logging.getLogger(__name__)
  42. # number of rooms to return. We'll stop once we hit this limit.
  43. MAX_ROOMS = 50
  44. # max number of events to return per room.
  45. MAX_ROOMS_PER_SPACE = 50
  46. # max number of federation servers to hit per room
  47. MAX_SERVERS_PER_SPACE = 3
  48. @attr.s(slots=True, frozen=True, auto_attribs=True)
  49. class _PaginationKey:
  50. """The key used to find unique pagination session."""
  51. # The first three entries match the request parameters (and cannot change
  52. # during a pagination session).
  53. room_id: str
  54. suggested_only: bool
  55. max_depth: Optional[int]
  56. # The randomly generated token.
  57. token: str
  58. @attr.s(slots=True, frozen=True, auto_attribs=True)
  59. class _PaginationSession:
  60. """The information that is stored for pagination."""
  61. # The time the pagination session was created, in milliseconds.
  62. creation_time_ms: int
  63. # The queue of rooms which are still to process.
  64. room_queue: List["_RoomQueueEntry"]
  65. # A set of rooms which have been processed.
  66. processed_rooms: Set[str]
  67. class RoomSummaryHandler:
  68. # A unique key used for pagination sessions for the room hierarchy endpoint.
  69. _PAGINATION_SESSION_TYPE = "room_hierarchy_pagination"
  70. # The time a pagination session remains valid for.
  71. _PAGINATION_SESSION_VALIDITY_PERIOD_MS = 5 * 60 * 1000
  72. def __init__(self, hs: "HomeServer"):
  73. self._event_auth_handler = hs.get_event_auth_handler()
  74. self._store = hs.get_datastores().main
  75. self._storage_controllers = hs.get_storage_controllers()
  76. self._event_serializer = hs.get_event_client_serializer()
  77. self._server_name = hs.hostname
  78. self._federation_client = hs.get_federation_client()
  79. self._ratelimiter = Ratelimiter(
  80. store=self._store,
  81. clock=hs.get_clock(),
  82. cfg=RatelimitSettings("<room summary>", per_second=5, burst_count=10),
  83. )
  84. # If a user tries to fetch the same page multiple times in quick succession,
  85. # only process the first attempt and return its result to subsequent requests.
  86. self._pagination_response_cache: ResponseCache[
  87. Tuple[str, str, bool, Optional[int], Optional[int], Optional[str]]
  88. ] = ResponseCache(
  89. hs.get_clock(),
  90. "get_room_hierarchy",
  91. )
  92. self._msc3266_enabled = hs.config.experimental.msc3266_enabled
  93. async def get_room_hierarchy(
  94. self,
  95. requester: Requester,
  96. requested_room_id: str,
  97. suggested_only: bool = False,
  98. max_depth: Optional[int] = None,
  99. limit: Optional[int] = None,
  100. from_token: Optional[str] = None,
  101. ) -> JsonDict:
  102. """
  103. Implementation of the room hierarchy C-S API.
  104. Args:
  105. requester: The user ID of the user making this request.
  106. requested_room_id: The room ID to start the hierarchy at (the "root" room).
  107. suggested_only: Whether we should only return children with the "suggested"
  108. flag set.
  109. max_depth: The maximum depth in the tree to explore, must be a
  110. non-negative integer.
  111. 0 would correspond to just the root room, 1 would include just
  112. the root room's children, etc.
  113. limit: An optional limit on the number of rooms to return per
  114. page. Must be a positive integer.
  115. from_token: An optional pagination token.
  116. Returns:
  117. The JSON hierarchy dictionary.
  118. """
  119. await self._ratelimiter.ratelimit(requester)
  120. # If a user tries to fetch the same page multiple times in quick succession,
  121. # only process the first attempt and return its result to subsequent requests.
  122. #
  123. # This is due to the pagination process mutating internal state, attempting
  124. # to process multiple requests for the same page will result in errors.
  125. return await self._pagination_response_cache.wrap(
  126. (
  127. requester.user.to_string(),
  128. requested_room_id,
  129. suggested_only,
  130. max_depth,
  131. limit,
  132. from_token,
  133. ),
  134. self._get_room_hierarchy,
  135. requester.user.to_string(),
  136. requested_room_id,
  137. suggested_only,
  138. max_depth,
  139. limit,
  140. from_token,
  141. )
  142. async def _get_room_hierarchy(
  143. self,
  144. requester: str,
  145. requested_room_id: str,
  146. suggested_only: bool = False,
  147. max_depth: Optional[int] = None,
  148. limit: Optional[int] = None,
  149. from_token: Optional[str] = None,
  150. ) -> JsonDict:
  151. """See docstring for SpaceSummaryHandler.get_room_hierarchy."""
  152. # First of all, check that the room is accessible.
  153. if not await self._is_local_room_accessible(requested_room_id, requester):
  154. raise UnstableSpecAuthError(
  155. 403,
  156. "User %s not in room %s, and room previews are disabled"
  157. % (requester, requested_room_id),
  158. errcode=Codes.NOT_JOINED,
  159. )
  160. # If this is continuing a previous session, pull the persisted data.
  161. if from_token:
  162. try:
  163. pagination_session = await self._store.get_session(
  164. session_type=self._PAGINATION_SESSION_TYPE,
  165. session_id=from_token,
  166. )
  167. except StoreError:
  168. raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)
  169. # If the requester, room ID, suggested-only, or max depth were modified
  170. # the session is invalid.
  171. if (
  172. requester != pagination_session["requester"]
  173. or requested_room_id != pagination_session["room_id"]
  174. or suggested_only != pagination_session["suggested_only"]
  175. or max_depth != pagination_session["max_depth"]
  176. ):
  177. raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)
  178. # Load the previous state.
  179. room_queue = [
  180. _RoomQueueEntry(*fields) for fields in pagination_session["room_queue"]
  181. ]
  182. processed_rooms = set(pagination_session["processed_rooms"])
  183. else:
  184. # The queue of rooms to process, the next room is last on the stack.
  185. room_queue = [_RoomQueueEntry(requested_room_id, ())]
  186. # Rooms we have already processed.
  187. processed_rooms = set()
  188. rooms_result: List[JsonDict] = []
  189. # Cap the limit to a server-side maximum.
  190. if limit is None:
  191. limit = MAX_ROOMS
  192. else:
  193. limit = min(limit, MAX_ROOMS)
  194. # Iterate through the queue until we reach the limit or run out of
  195. # rooms to include.
  196. while room_queue and len(rooms_result) < limit:
  197. queue_entry = room_queue.pop()
  198. room_id = queue_entry.room_id
  199. current_depth = queue_entry.depth
  200. if room_id in processed_rooms:
  201. # already done this room
  202. continue
  203. logger.debug("Processing room %s", room_id)
  204. # A map of summaries for children rooms that might be returned over
  205. # federation. The rationale for caching these and *maybe* using them
  206. # is to prefer any information local to the homeserver before trusting
  207. # data received over federation.
  208. children_room_entries: Dict[str, JsonDict] = {}
  209. # A set of room IDs which are children that did not have information
  210. # returned over federation and are known to be inaccessible to the
  211. # current server. We should not reach out over federation to try to
  212. # summarise these rooms.
  213. inaccessible_children: Set[str] = set()
  214. # If the room is known locally, summarise it!
  215. is_in_room = await self._store.is_host_joined(room_id, self._server_name)
  216. if is_in_room:
  217. room_entry = await self._summarize_local_room(
  218. requester,
  219. None,
  220. room_id,
  221. suggested_only,
  222. )
  223. # Otherwise, attempt to use information for federation.
  224. else:
  225. # A previous call might have included information for this room.
  226. # It can be used if either:
  227. #
  228. # 1. The room is not a space.
  229. # 2. The maximum depth has been achieved (since no children
  230. # information is needed).
  231. if queue_entry.remote_room and (
  232. queue_entry.remote_room.get("room_type") != RoomTypes.SPACE
  233. or (max_depth is not None and current_depth >= max_depth)
  234. ):
  235. room_entry = _RoomEntry(
  236. queue_entry.room_id, queue_entry.remote_room
  237. )
  238. # If the above isn't true, attempt to fetch the room
  239. # information over federation.
  240. else:
  241. (
  242. room_entry,
  243. children_room_entries,
  244. inaccessible_children,
  245. ) = await self._summarize_remote_room_hierarchy(
  246. queue_entry,
  247. suggested_only,
  248. )
  249. # Ensure this room is accessible to the requester (and not just
  250. # the homeserver).
  251. if room_entry and not await self._is_remote_room_accessible(
  252. requester, queue_entry.room_id, room_entry.room
  253. ):
  254. room_entry = None
  255. # This room has been processed and should be ignored if it appears
  256. # elsewhere in the hierarchy.
  257. processed_rooms.add(room_id)
  258. # There may or may not be a room entry based on whether it is
  259. # inaccessible to the requesting user.
  260. if room_entry:
  261. # Add the room (including the stripped m.space.child events).
  262. rooms_result.append(room_entry.as_json(for_client=True))
  263. # If this room is not at the max-depth, check if there are any
  264. # children to process.
  265. if max_depth is None or current_depth < max_depth:
  266. # The children get added in reverse order so that the next
  267. # room to process, according to the ordering, is the last
  268. # item in the list.
  269. room_queue.extend(
  270. _RoomQueueEntry(
  271. ev["state_key"],
  272. ev["content"]["via"],
  273. current_depth + 1,
  274. children_room_entries.get(ev["state_key"]),
  275. )
  276. for ev in reversed(room_entry.children_state_events)
  277. if ev["type"] == EventTypes.SpaceChild
  278. and ev["state_key"] not in inaccessible_children
  279. )
  280. result: JsonDict = {"rooms": rooms_result}
  281. # If there's additional data, generate a pagination token (and persist state).
  282. if room_queue:
  283. result["next_batch"] = await self._store.create_session(
  284. session_type=self._PAGINATION_SESSION_TYPE,
  285. value={
  286. # Information which must be identical across pagination.
  287. "requester": requester,
  288. "room_id": requested_room_id,
  289. "suggested_only": suggested_only,
  290. "max_depth": max_depth,
  291. # The stored state.
  292. "room_queue": [
  293. attr.astuple(room_entry) for room_entry in room_queue
  294. ],
  295. "processed_rooms": list(processed_rooms),
  296. },
  297. expiry_ms=self._PAGINATION_SESSION_VALIDITY_PERIOD_MS,
  298. )
  299. return result
  300. async def get_federation_hierarchy(
  301. self,
  302. origin: str,
  303. requested_room_id: str,
  304. suggested_only: bool,
  305. ) -> JsonDict:
  306. """
  307. Implementation of the room hierarchy Federation API.
  308. This is similar to get_room_hierarchy, but does not recurse into the space.
  309. It also considers whether anyone on the server may be able to access the
  310. room, as opposed to whether a specific user can.
  311. Args:
  312. origin: The server requesting the spaces summary.
  313. requested_room_id: The room ID to start the hierarchy at (the "root" room).
  314. suggested_only: whether we should only return children with the "suggested"
  315. flag set.
  316. Returns:
  317. The JSON hierarchy dictionary.
  318. """
  319. root_room_entry = await self._summarize_local_room(
  320. None, origin, requested_room_id, suggested_only
  321. )
  322. if root_room_entry is None:
  323. # Room is inaccessible to the requesting server.
  324. raise SynapseError(404, "Unknown room: %s" % (requested_room_id,))
  325. children_rooms_result: List[JsonDict] = []
  326. inaccessible_children: List[str] = []
  327. # Iterate through each child and potentially add it, but not its children,
  328. # to the response.
  329. for child_room in itertools.islice(
  330. root_room_entry.children_state_events, MAX_ROOMS_PER_SPACE
  331. ):
  332. room_id = child_room.get("state_key")
  333. assert isinstance(room_id, str)
  334. # If the room is unknown, skip it.
  335. if not await self._store.is_host_joined(room_id, self._server_name):
  336. continue
  337. room_entry = await self._summarize_local_room(
  338. None, origin, room_id, suggested_only, include_children=False
  339. )
  340. # If the room is accessible, include it in the results.
  341. #
  342. # Note that only the room summary (without information on children)
  343. # is included in the summary.
  344. if room_entry:
  345. children_rooms_result.append(room_entry.room)
  346. # Otherwise, note that the requesting server shouldn't bother
  347. # trying to summarize this room - they do not have access to it.
  348. else:
  349. inaccessible_children.append(room_id)
  350. return {
  351. # Include the requested room (including the stripped children events).
  352. "room": root_room_entry.as_json(),
  353. "children": children_rooms_result,
  354. "inaccessible_children": inaccessible_children,
  355. }
  356. async def _summarize_local_room(
  357. self,
  358. requester: Optional[str],
  359. origin: Optional[str],
  360. room_id: str,
  361. suggested_only: bool,
  362. include_children: bool = True,
  363. ) -> Optional["_RoomEntry"]:
  364. """
  365. Generate a room entry and a list of event entries for a given room.
  366. Args:
  367. requester:
  368. The user requesting the summary, if it is a local request. None
  369. if this is a federation request.
  370. origin:
  371. The server requesting the summary, if it is a federation request.
  372. None if this is a local request.
  373. room_id: The room ID to summarize.
  374. suggested_only: True if only suggested children should be returned.
  375. Otherwise, all children are returned.
  376. include_children:
  377. Whether to include the events of any children.
  378. Returns:
  379. A room entry if the room should be returned. None, otherwise.
  380. """
  381. if not await self._is_local_room_accessible(room_id, requester, origin):
  382. return None
  383. room_entry = await self._build_room_entry(room_id, for_federation=bool(origin))
  384. # If the room is not a space return just the room information.
  385. if room_entry.get("room_type") != RoomTypes.SPACE or not include_children:
  386. return _RoomEntry(room_id, room_entry)
  387. # Otherwise, look for child rooms/spaces.
  388. child_events = await self._get_child_events(room_id)
  389. if suggested_only:
  390. # we only care about suggested children
  391. child_events = filter(_is_suggested_child_event, child_events)
  392. stripped_events: List[JsonDict] = [
  393. {
  394. "type": e.type,
  395. "state_key": e.state_key,
  396. "content": e.content,
  397. "sender": e.sender,
  398. "origin_server_ts": e.origin_server_ts,
  399. }
  400. for e in child_events
  401. ]
  402. return _RoomEntry(room_id, room_entry, stripped_events)
  403. async def _summarize_remote_room_hierarchy(
  404. self, room: "_RoomQueueEntry", suggested_only: bool
  405. ) -> Tuple[Optional["_RoomEntry"], Dict[str, JsonDict], Set[str]]:
  406. """
  407. Request room entries and a list of event entries for a given room by querying a remote server.
  408. Args:
  409. room: The room to summarize.
  410. suggested_only: True if only suggested children should be returned.
  411. Otherwise, all children are returned.
  412. Returns:
  413. A tuple of:
  414. The room entry.
  415. Partial room data return over federation.
  416. A set of inaccessible children room IDs.
  417. """
  418. room_id = room.room_id
  419. logger.info("Requesting summary for %s via %s", room_id, room.via)
  420. via = itertools.islice(room.via, MAX_SERVERS_PER_SPACE)
  421. try:
  422. (
  423. room_response,
  424. children_state_events,
  425. children,
  426. inaccessible_children,
  427. ) = await self._federation_client.get_room_hierarchy(
  428. via,
  429. room_id,
  430. suggested_only=suggested_only,
  431. )
  432. except Exception as e:
  433. logger.warning(
  434. "Unable to get hierarchy of %s via federation: %s",
  435. room_id,
  436. e,
  437. exc_info=logger.isEnabledFor(logging.DEBUG),
  438. )
  439. return None, {}, set()
  440. # Map the children to their room ID.
  441. children_by_room_id = {
  442. c["room_id"]: c
  443. for c in children
  444. if "room_id" in c and isinstance(c["room_id"], str)
  445. }
  446. return (
  447. _RoomEntry(room_id, room_response, children_state_events),
  448. children_by_room_id,
  449. set(inaccessible_children),
  450. )
  451. async def _is_local_room_accessible(
  452. self, room_id: str, requester: Optional[str], origin: Optional[str] = None
  453. ) -> bool:
  454. """
  455. Calculate whether the room should be shown to the requester.
  456. It should return true if:
  457. * The requesting user is joined or can join the room (per MSC3173); or
  458. * The origin server has any user that is joined or can join the room; or
  459. * The history visibility is set to world readable.
  460. Args:
  461. room_id: The room ID to check accessibility of.
  462. requester:
  463. The user making the request, if it is a local request.
  464. None if this is a federation request.
  465. origin:
  466. The server making the request, if it is a federation request.
  467. None if this is a local request.
  468. Returns:
  469. True if the room is accessible to the requesting user or server.
  470. """
  471. state_ids = await self._storage_controllers.state.get_current_state_ids(room_id)
  472. # If there's no state for the room, it isn't known.
  473. if not state_ids:
  474. # The user might have a pending invite for the room.
  475. if requester and await self._store.get_invite_for_local_user_in_room(
  476. requester, room_id
  477. ):
  478. return True
  479. logger.info("room %s is unknown, omitting from summary", room_id)
  480. return False
  481. try:
  482. room_version = await self._store.get_room_version(room_id)
  483. except UnsupportedRoomVersionError:
  484. # If a room with an unsupported room version is encountered, ignore
  485. # it to avoid breaking the entire summary response.
  486. return False
  487. # Include the room if it has join rules of public or knock.
  488. join_rules_event_id = state_ids.get((EventTypes.JoinRules, ""))
  489. if join_rules_event_id:
  490. join_rules_event = await self._store.get_event(join_rules_event_id)
  491. join_rule = join_rules_event.content.get("join_rule")
  492. if (
  493. join_rule == JoinRules.PUBLIC
  494. or (room_version.knock_join_rule and join_rule == JoinRules.KNOCK)
  495. or (
  496. room_version.knock_restricted_join_rule
  497. and join_rule == JoinRules.KNOCK_RESTRICTED
  498. )
  499. ):
  500. return True
  501. # Include the room if it is peekable.
  502. hist_vis_event_id = state_ids.get((EventTypes.RoomHistoryVisibility, ""))
  503. if hist_vis_event_id:
  504. hist_vis_ev = await self._store.get_event(hist_vis_event_id)
  505. hist_vis = hist_vis_ev.content.get("history_visibility")
  506. if hist_vis == HistoryVisibility.WORLD_READABLE:
  507. return True
  508. # Otherwise we need to check information specific to the user or server.
  509. # If we have an authenticated requesting user, check if they are a member
  510. # of the room (or can join the room).
  511. if requester:
  512. member_event_id = state_ids.get((EventTypes.Member, requester), None)
  513. # If they're in the room they can see info on it.
  514. if member_event_id:
  515. member_event = await self._store.get_event(member_event_id)
  516. if member_event.membership in (Membership.JOIN, Membership.INVITE):
  517. return True
  518. # Otherwise, check if they should be allowed access via membership in a space.
  519. if await self._event_auth_handler.has_restricted_join_rules(
  520. state_ids, room_version
  521. ):
  522. allowed_rooms = (
  523. await self._event_auth_handler.get_rooms_that_allow_join(state_ids)
  524. )
  525. if await self._event_auth_handler.is_user_in_rooms(
  526. allowed_rooms, requester
  527. ):
  528. return True
  529. # If this is a request over federation, check if the host is in the room or
  530. # has a user who could join the room.
  531. elif origin:
  532. if await self._event_auth_handler.is_host_in_room(
  533. room_id, origin
  534. ) or await self._store.is_host_invited(room_id, origin):
  535. return True
  536. # Alternately, if the host has a user in any of the spaces specified
  537. # for access, then the host can see this room (and should do filtering
  538. # if the requester cannot see it).
  539. if await self._event_auth_handler.has_restricted_join_rules(
  540. state_ids, room_version
  541. ):
  542. allowed_rooms = (
  543. await self._event_auth_handler.get_rooms_that_allow_join(state_ids)
  544. )
  545. for space_id in allowed_rooms:
  546. if await self._event_auth_handler.is_host_in_room(space_id, origin):
  547. return True
  548. logger.info(
  549. "room %s is unpeekable and requester %s is not a member / not allowed to join, omitting from summary",
  550. room_id,
  551. requester or origin,
  552. )
  553. return False
  554. async def _is_remote_room_accessible(
  555. self, requester: Optional[str], room_id: str, room: JsonDict
  556. ) -> bool:
  557. """
  558. Calculate whether the room received over federation should be shown to the requester.
  559. It should return true if:
  560. * The requester is joined or can join the room (per MSC3173).
  561. * The history visibility is set to world readable.
  562. Note that the local server is not in the requested room (which is why the
  563. remote call was made in the first place), but the user could have access
  564. due to an invite, etc.
  565. Args:
  566. requester: The user requesting the summary. If not passed only world
  567. readability is checked.
  568. room_id: The room ID returned over federation.
  569. room: The summary of the room returned over federation.
  570. Returns:
  571. True if the room is accessible to the requesting user.
  572. """
  573. # The API doesn't return the room version so assume that a
  574. # join rule of knock is valid.
  575. if (
  576. room.get("join_rule")
  577. in (JoinRules.PUBLIC, JoinRules.KNOCK, JoinRules.KNOCK_RESTRICTED)
  578. or room.get("world_readable") is True
  579. ):
  580. return True
  581. elif not requester:
  582. return False
  583. # Check if the user is a member of any of the allowed rooms from the response.
  584. allowed_rooms = room.get("allowed_room_ids")
  585. if allowed_rooms and isinstance(allowed_rooms, list):
  586. if await self._event_auth_handler.is_user_in_rooms(
  587. allowed_rooms, requester
  588. ):
  589. return True
  590. # Finally, check locally if we can access the room. The user might
  591. # already be in the room (if it was a child room), or there might be a
  592. # pending invite, etc.
  593. return await self._is_local_room_accessible(room_id, requester)
  594. async def _build_room_entry(self, room_id: str, for_federation: bool) -> JsonDict:
  595. """
  596. Generate en entry summarising a single room.
  597. Args:
  598. room_id: The room ID to summarize.
  599. for_federation: True if this is a summary requested over federation
  600. (which includes additional fields).
  601. Returns:
  602. The JSON dictionary for the room.
  603. """
  604. stats = await self._store.get_room_with_stats(room_id)
  605. # currently this should be impossible because we call
  606. # _is_local_room_accessible on the room before we get here, so
  607. # there should always be an entry
  608. assert stats is not None, "unable to retrieve stats for %s" % (room_id,)
  609. entry: JsonDict = {
  610. "room_id": stats.room_id,
  611. "name": stats.name,
  612. "topic": stats.topic,
  613. "canonical_alias": stats.canonical_alias,
  614. "num_joined_members": stats.joined_members,
  615. "avatar_url": stats.avatar,
  616. "join_rule": stats.join_rules,
  617. "world_readable": (
  618. stats.history_visibility == HistoryVisibility.WORLD_READABLE
  619. ),
  620. "guest_can_join": stats.guest_access == "can_join",
  621. "room_type": stats.room_type,
  622. }
  623. if self._msc3266_enabled:
  624. entry["im.nheko.summary.version"] = stats.version
  625. entry["im.nheko.summary.encryption"] = stats.encryption
  626. # Federation requests need to provide additional information so the
  627. # requested server is able to filter the response appropriately.
  628. if for_federation:
  629. current_state_ids = (
  630. await self._storage_controllers.state.get_current_state_ids(room_id)
  631. )
  632. room_version = await self._store.get_room_version(room_id)
  633. if await self._event_auth_handler.has_restricted_join_rules(
  634. current_state_ids, room_version
  635. ):
  636. allowed_rooms = (
  637. await self._event_auth_handler.get_rooms_that_allow_join(
  638. current_state_ids
  639. )
  640. )
  641. if allowed_rooms:
  642. entry["allowed_room_ids"] = allowed_rooms
  643. # Filter out Nones – rather omit the field altogether
  644. room_entry = {k: v for k, v in entry.items() if v is not None}
  645. return room_entry
  646. async def _get_child_events(self, room_id: str) -> Iterable[EventBase]:
  647. """
  648. Get the child events for a given room.
  649. The returned results are sorted for stability.
  650. Args:
  651. room_id: The room id to get the children of.
  652. Returns:
  653. An iterable of sorted child events.
  654. """
  655. # look for child rooms/spaces.
  656. current_state_ids = await self._storage_controllers.state.get_current_state_ids(
  657. room_id
  658. )
  659. events = await self._store.get_events_as_list(
  660. [
  661. event_id
  662. for key, event_id in current_state_ids.items()
  663. if key[0] == EventTypes.SpaceChild
  664. ]
  665. )
  666. # filter out any events without a "via" (which implies it has been redacted),
  667. # and order to ensure we return stable results.
  668. return sorted(filter(_has_valid_via, events), key=_child_events_comparison_key)
  669. async def get_room_summary(
  670. self,
  671. requester: Optional[str],
  672. room_id: str,
  673. remote_room_hosts: Optional[List[str]] = None,
  674. ) -> JsonDict:
  675. """
  676. Implementation of the room summary C-S API from MSC3266
  677. Args:
  678. requester: user id of the user making this request, will be None
  679. for unauthenticated requests
  680. room_id: room id to summarise.
  681. remote_room_hosts: a list of homeservers to try fetching data through
  682. if we don't know it ourselves
  683. Returns:
  684. summary dict to return
  685. """
  686. is_in_room = await self._store.is_host_joined(room_id, self._server_name)
  687. if is_in_room:
  688. room_entry = await self._summarize_local_room(
  689. requester,
  690. None,
  691. room_id,
  692. # Suggested-only doesn't matter since no children are requested.
  693. suggested_only=False,
  694. include_children=False,
  695. )
  696. if not room_entry:
  697. raise NotFoundError("Room not found or is not accessible")
  698. room_summary = room_entry.room
  699. # If there was a requester, add their membership.
  700. if requester:
  701. (
  702. membership,
  703. _,
  704. ) = await self._store.get_local_current_membership_for_user_in_room(
  705. requester, room_id
  706. )
  707. room_summary["membership"] = membership or "leave"
  708. else:
  709. # Reuse the hierarchy query over federation
  710. if remote_room_hosts is None:
  711. raise SynapseError(400, "Missing via to query remote room")
  712. (
  713. room_entry,
  714. children_room_entries,
  715. inaccessible_children,
  716. ) = await self._summarize_remote_room_hierarchy(
  717. _RoomQueueEntry(room_id, remote_room_hosts),
  718. suggested_only=True,
  719. )
  720. # The results over federation might include rooms that we, as the
  721. # requesting server, are allowed to see, but the requesting user is
  722. # not permitted to see.
  723. #
  724. # Filter the returned results to only what is accessible to the user.
  725. if not room_entry or not await self._is_remote_room_accessible(
  726. requester, room_entry.room_id, room_entry.room
  727. ):
  728. raise NotFoundError("Room not found or is not accessible")
  729. room = dict(room_entry.room)
  730. room.pop("allowed_room_ids", None)
  731. # If there was a requester, add their membership.
  732. # We keep the membership in the local membership table unless the
  733. # room is purged even for remote rooms.
  734. if requester:
  735. (
  736. membership,
  737. _,
  738. ) = await self._store.get_local_current_membership_for_user_in_room(
  739. requester, room_id
  740. )
  741. room["membership"] = membership or "leave"
  742. return room
  743. return room_summary
  744. @attr.s(frozen=True, slots=True, auto_attribs=True)
  745. class _RoomQueueEntry:
  746. # The room ID of this entry.
  747. room_id: str
  748. # The server to query if the room is not known locally.
  749. via: StrCollection
  750. # The minimum number of hops necessary to get to this room (compared to the
  751. # originally requested room).
  752. depth: int = 0
  753. # The room summary for this room returned via federation. This will only be
  754. # used if the room is not known locally (and is not a space).
  755. remote_room: Optional[JsonDict] = None
  756. @attr.s(frozen=True, slots=True, auto_attribs=True)
  757. class _RoomEntry:
  758. room_id: str
  759. # The room summary for this room.
  760. room: JsonDict
  761. # An iterable of the sorted, stripped children events for children of this room.
  762. #
  763. # This may not include all children.
  764. children_state_events: Sequence[JsonDict] = ()
  765. def as_json(self, for_client: bool = False) -> JsonDict:
  766. """
  767. Returns a JSON dictionary suitable for the room hierarchy endpoint.
  768. It returns the room summary including the stripped m.space.child events
  769. as a sub-key.
  770. Args:
  771. for_client: If true, any server-server only fields are stripped from
  772. the result.
  773. """
  774. result = dict(self.room)
  775. # Before returning to the client, remove the allowed_room_ids key, if it
  776. # exists.
  777. if for_client:
  778. result.pop("allowed_room_ids", False)
  779. result["children_state"] = self.children_state_events
  780. return result
  781. def _has_valid_via(e: EventBase) -> bool:
  782. via = e.content.get("via")
  783. if not via or not isinstance(via, list):
  784. return False
  785. for v in via:
  786. if not isinstance(v, str):
  787. logger.debug("Ignoring edge event %s with invalid via entry", e.event_id)
  788. return False
  789. return True
  790. def _is_suggested_child_event(edge_event: EventBase) -> bool:
  791. suggested = edge_event.content.get("suggested")
  792. if isinstance(suggested, bool) and suggested:
  793. return True
  794. logger.debug("Ignorning not-suggested child %s", edge_event.state_key)
  795. return False
  796. # Order may only contain characters in the range of \x20 (space) to \x7E (~) inclusive.
  797. _INVALID_ORDER_CHARS_RE = re.compile(r"[^\x20-\x7E]")
  798. def _child_events_comparison_key(
  799. child: EventBase,
  800. ) -> Tuple[bool, Optional[str], int, str]:
  801. """
  802. Generate a value for comparing two child events for ordering.
  803. The rules for ordering are:
  804. 1. The 'order' key, if it is valid.
  805. 2. The 'origin_server_ts' of the 'm.space.child' event.
  806. 3. The 'room_id'.
  807. Args:
  808. child: The event for generating a comparison key.
  809. Returns:
  810. The comparison key as a tuple of:
  811. False if the ordering is valid.
  812. The 'order' field or None if it is not given or invalid.
  813. The 'origin_server_ts' field.
  814. The room ID.
  815. """
  816. order = child.content.get("order")
  817. # If order is not a string or doesn't meet the requirements, ignore it.
  818. if not isinstance(order, str):
  819. order = None
  820. elif len(order) > 50 or _INVALID_ORDER_CHARS_RE.search(order):
  821. order = None
  822. # Items without an order come last.
  823. return order is None, order, child.origin_server_ts, child.room_id