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.
 
 
 
 
 
 

540 lines
20 KiB

  1. # Copyright 2014 - 2016 OpenMarket Ltd
  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 logging
  15. from typing import TYPE_CHECKING, Any, Optional, Tuple
  16. import attr
  17. import msgpack
  18. from unpaddedbase64 import decode_base64, encode_base64
  19. from synapse.api.constants import (
  20. EventContentFields,
  21. EventTypes,
  22. GuestAccess,
  23. HistoryVisibility,
  24. JoinRules,
  25. PublicRoomsFilterFields,
  26. )
  27. from synapse.api.errors import (
  28. Codes,
  29. HttpResponseException,
  30. RequestSendFailed,
  31. SynapseError,
  32. )
  33. from synapse.types import JsonDict, JsonMapping, ThirdPartyInstanceID
  34. from synapse.util.caches.descriptors import _CacheContext, cached
  35. from synapse.util.caches.response_cache import ResponseCache
  36. if TYPE_CHECKING:
  37. from synapse.server import HomeServer
  38. logger = logging.getLogger(__name__)
  39. REMOTE_ROOM_LIST_POLL_INTERVAL = 60 * 1000
  40. # This is used to indicate we should only return rooms published to the main list.
  41. EMPTY_THIRD_PARTY_ID = ThirdPartyInstanceID(None, None)
  42. class RoomListHandler:
  43. def __init__(self, hs: "HomeServer"):
  44. self.store = hs.get_datastores().main
  45. self._storage_controllers = hs.get_storage_controllers()
  46. self.hs = hs
  47. self.enable_room_list_search = hs.config.roomdirectory.enable_room_list_search
  48. self.response_cache: ResponseCache[
  49. Tuple[Optional[int], Optional[str], Optional[ThirdPartyInstanceID]]
  50. ] = ResponseCache(hs.get_clock(), "room_list")
  51. self.remote_response_cache: ResponseCache[
  52. Tuple[str, Optional[int], Optional[str], bool, Optional[str]]
  53. ] = ResponseCache(hs.get_clock(), "remote_room_list", timeout_ms=30 * 1000)
  54. async def get_local_public_room_list(
  55. self,
  56. limit: Optional[int] = None,
  57. since_token: Optional[str] = None,
  58. search_filter: Optional[dict] = None,
  59. network_tuple: Optional[ThirdPartyInstanceID] = EMPTY_THIRD_PARTY_ID,
  60. from_federation: bool = False,
  61. ) -> JsonDict:
  62. """Generate a local public room list.
  63. There are multiple different lists: the main one plus one per third
  64. party network. A client can ask for a specific list or to return all.
  65. Args:
  66. limit
  67. since_token
  68. search_filter
  69. network_tuple: Which public list to use.
  70. This can be (None, None) to indicate the main list, or a particular
  71. appservice and network id to use an appservice specific one.
  72. Setting to None returns all public rooms across all lists.
  73. from_federation: true iff the request comes from the federation API
  74. """
  75. if not self.enable_room_list_search:
  76. return {"chunk": [], "total_room_count_estimate": 0}
  77. logger.info(
  78. "Getting public room list: limit=%r, since=%r, search=%r, network=%r",
  79. limit,
  80. since_token,
  81. bool(search_filter),
  82. network_tuple,
  83. )
  84. if search_filter:
  85. # We explicitly don't bother caching searches or requests for
  86. # appservice specific lists.
  87. logger.info("Bypassing cache as search request.")
  88. return await self._get_public_room_list(
  89. limit,
  90. since_token,
  91. search_filter,
  92. network_tuple=network_tuple,
  93. from_federation=from_federation,
  94. )
  95. key = (limit, since_token, network_tuple)
  96. return await self.response_cache.wrap(
  97. key,
  98. self._get_public_room_list,
  99. limit,
  100. since_token,
  101. network_tuple=network_tuple,
  102. from_federation=from_federation,
  103. )
  104. async def _get_public_room_list(
  105. self,
  106. limit: Optional[int] = None,
  107. since_token: Optional[str] = None,
  108. search_filter: Optional[dict] = None,
  109. network_tuple: Optional[ThirdPartyInstanceID] = EMPTY_THIRD_PARTY_ID,
  110. from_federation: bool = False,
  111. ) -> JsonDict:
  112. """Generate a public room list.
  113. Args:
  114. limit: Maximum amount of rooms to return.
  115. since_token:
  116. search_filter: Dictionary to filter rooms by.
  117. network_tuple: Which public list to use.
  118. This can be (None, None) to indicate the main list, or a particular
  119. appservice and network id to use an appservice specific one.
  120. Setting to None returns all public rooms across all lists.
  121. from_federation: Whether this request originated from a
  122. federating server or a client. Used for room filtering.
  123. """
  124. # Pagination tokens work by storing the room ID sent in the last batch,
  125. # plus the direction (forwards or backwards). Next batch tokens always
  126. # go forwards, prev batch tokens always go backwards.
  127. if since_token:
  128. batch_token = RoomListNextBatch.from_token(since_token)
  129. bounds: Optional[Tuple[int, str]] = (
  130. batch_token.last_joined_members,
  131. batch_token.last_room_id,
  132. )
  133. forwards = batch_token.direction_is_forward
  134. has_batch_token = True
  135. else:
  136. bounds = None
  137. forwards = True
  138. has_batch_token = False
  139. # we request one more than wanted to see if there are more pages to come
  140. probing_limit = limit + 1 if limit is not None else None
  141. results = await self.store.get_largest_public_rooms(
  142. network_tuple,
  143. search_filter,
  144. probing_limit,
  145. bounds=bounds,
  146. forwards=forwards,
  147. ignore_non_federatable=from_federation,
  148. )
  149. def build_room_entry(room: JsonDict) -> JsonDict:
  150. entry = {
  151. "room_id": room["room_id"],
  152. "name": room["name"],
  153. "topic": room["topic"],
  154. "canonical_alias": room["canonical_alias"],
  155. "num_joined_members": room["joined_members"],
  156. "avatar_url": room["avatar"],
  157. "world_readable": room["history_visibility"]
  158. == HistoryVisibility.WORLD_READABLE,
  159. "guest_can_join": room["guest_access"] == "can_join",
  160. "join_rule": room["join_rules"],
  161. "room_type": room["room_type"],
  162. }
  163. # Filter out Nones – rather omit the field altogether
  164. return {k: v for k, v in entry.items() if v is not None}
  165. results = [build_room_entry(r) for r in results]
  166. response: JsonDict = {}
  167. num_results = len(results)
  168. if limit is not None:
  169. more_to_come = num_results == probing_limit
  170. # Depending on direction we trim either the front or back.
  171. if forwards:
  172. results = results[:limit]
  173. else:
  174. results = results[-limit:]
  175. else:
  176. more_to_come = False
  177. if num_results > 0:
  178. final_entry = results[-1]
  179. initial_entry = results[0]
  180. if forwards:
  181. if has_batch_token:
  182. # If there was a token given then we assume that there
  183. # must be previous results.
  184. response["prev_batch"] = RoomListNextBatch(
  185. last_joined_members=initial_entry["num_joined_members"],
  186. last_room_id=initial_entry["room_id"],
  187. direction_is_forward=False,
  188. ).to_token()
  189. if more_to_come:
  190. response["next_batch"] = RoomListNextBatch(
  191. last_joined_members=final_entry["num_joined_members"],
  192. last_room_id=final_entry["room_id"],
  193. direction_is_forward=True,
  194. ).to_token()
  195. else:
  196. if has_batch_token:
  197. response["next_batch"] = RoomListNextBatch(
  198. last_joined_members=final_entry["num_joined_members"],
  199. last_room_id=final_entry["room_id"],
  200. direction_is_forward=True,
  201. ).to_token()
  202. if more_to_come:
  203. response["prev_batch"] = RoomListNextBatch(
  204. last_joined_members=initial_entry["num_joined_members"],
  205. last_room_id=initial_entry["room_id"],
  206. direction_is_forward=False,
  207. ).to_token()
  208. response["chunk"] = results
  209. response["total_room_count_estimate"] = await self.store.count_public_rooms(
  210. network_tuple,
  211. ignore_non_federatable=from_federation,
  212. search_filter=search_filter,
  213. )
  214. return response
  215. @cached(num_args=1, cache_context=True)
  216. async def generate_room_entry(
  217. self,
  218. room_id: str,
  219. num_joined_users: int,
  220. cache_context: _CacheContext,
  221. with_alias: bool = True,
  222. allow_private: bool = False,
  223. ) -> Optional[JsonMapping]:
  224. """Returns the entry for a room
  225. Args:
  226. room_id: The room's ID.
  227. num_joined_users: Number of users in the room.
  228. cache_context: Information for cached responses.
  229. with_alias: Whether to return the room's aliases in the result.
  230. allow_private: Whether invite-only rooms should be shown.
  231. Returns:
  232. Returns a room entry as a dictionary, or None if this
  233. room was determined not to be shown publicly.
  234. """
  235. result = {"room_id": room_id, "num_joined_members": num_joined_users}
  236. if with_alias:
  237. aliases = await self.store.get_aliases_for_room(
  238. room_id, on_invalidate=cache_context.invalidate
  239. )
  240. if aliases:
  241. result["aliases"] = aliases
  242. current_state_ids = await self._storage_controllers.state.get_current_state_ids(
  243. room_id, on_invalidate=cache_context.invalidate
  244. )
  245. if not current_state_ids:
  246. # We're not in the room, so may as well bail out here.
  247. return result
  248. event_map = await self.store.get_events(
  249. [
  250. event_id
  251. for key, event_id in current_state_ids.items()
  252. if key[0]
  253. in (
  254. EventTypes.Create,
  255. EventTypes.JoinRules,
  256. EventTypes.Name,
  257. EventTypes.Topic,
  258. EventTypes.CanonicalAlias,
  259. EventTypes.RoomHistoryVisibility,
  260. EventTypes.GuestAccess,
  261. "m.room.avatar",
  262. )
  263. ]
  264. )
  265. current_state = {(ev.type, ev.state_key): ev for ev in event_map.values()}
  266. # Double check that this is actually a public room.
  267. join_rules_event = current_state.get((EventTypes.JoinRules, ""))
  268. if join_rules_event:
  269. join_rule = join_rules_event.content.get("join_rule", None)
  270. if not allow_private and join_rule and join_rule != JoinRules.PUBLIC:
  271. return None
  272. # Return whether this room is open to federation users or not
  273. create_event = current_state[EventTypes.Create, ""]
  274. result["m.federate"] = create_event.content.get(
  275. EventContentFields.FEDERATE, True
  276. )
  277. name_event = current_state.get((EventTypes.Name, ""))
  278. if name_event:
  279. name = name_event.content.get("name", None)
  280. if name:
  281. result["name"] = name
  282. topic_event = current_state.get((EventTypes.Topic, ""))
  283. if topic_event:
  284. topic = topic_event.content.get("topic", None)
  285. if topic:
  286. result["topic"] = topic
  287. canonical_event = current_state.get((EventTypes.CanonicalAlias, ""))
  288. if canonical_event:
  289. canonical_alias = canonical_event.content.get("alias", None)
  290. if canonical_alias:
  291. result["canonical_alias"] = canonical_alias
  292. visibility_event = current_state.get((EventTypes.RoomHistoryVisibility, ""))
  293. visibility = None
  294. if visibility_event:
  295. visibility = visibility_event.content.get("history_visibility", None)
  296. result["world_readable"] = visibility == HistoryVisibility.WORLD_READABLE
  297. guest_event = current_state.get((EventTypes.GuestAccess, ""))
  298. guest = None
  299. if guest_event:
  300. guest = guest_event.content.get(EventContentFields.GUEST_ACCESS)
  301. result["guest_can_join"] = guest == GuestAccess.CAN_JOIN
  302. avatar_event = current_state.get(("m.room.avatar", ""))
  303. if avatar_event:
  304. avatar_url = avatar_event.content.get("url", None)
  305. if avatar_url:
  306. result["avatar_url"] = avatar_url
  307. return result
  308. async def get_remote_public_room_list(
  309. self,
  310. server_name: str,
  311. limit: Optional[int] = None,
  312. since_token: Optional[str] = None,
  313. search_filter: Optional[dict] = None,
  314. include_all_networks: bool = False,
  315. third_party_instance_id: Optional[str] = None,
  316. ) -> JsonDict:
  317. """Get the public room list from remote server
  318. Raises:
  319. SynapseError
  320. """
  321. if not self.enable_room_list_search:
  322. return {"chunk": [], "total_room_count_estimate": 0}
  323. if search_filter:
  324. # Searching across federation is defined in MSC2197.
  325. # However, the remote homeserver may or may not actually support it.
  326. # So we first try an MSC2197 remote-filtered search, then fall back
  327. # to a locally-filtered search if we must.
  328. try:
  329. res = await self._get_remote_list_cached(
  330. server_name,
  331. limit=limit,
  332. since_token=since_token,
  333. include_all_networks=include_all_networks,
  334. third_party_instance_id=third_party_instance_id,
  335. search_filter=search_filter,
  336. )
  337. return res
  338. except HttpResponseException as hre:
  339. syn_err = hre.to_synapse_error()
  340. if hre.code in (404, 405) or syn_err.errcode in (
  341. Codes.UNRECOGNIZED,
  342. Codes.NOT_FOUND,
  343. ):
  344. logger.debug("Falling back to locally-filtered /publicRooms")
  345. else:
  346. # Not an error that should trigger a fallback.
  347. raise SynapseError(502, "Failed to fetch room list")
  348. except RequestSendFailed:
  349. # Not an error that should trigger a fallback.
  350. raise SynapseError(502, "Failed to fetch room list")
  351. # if we reach this point, then we fall back to the situation where
  352. # we currently don't support searching across federation, so we have
  353. # to do it manually without pagination
  354. limit = None
  355. since_token = None
  356. try:
  357. res = await self._get_remote_list_cached(
  358. server_name,
  359. limit=limit,
  360. since_token=since_token,
  361. include_all_networks=include_all_networks,
  362. third_party_instance_id=third_party_instance_id,
  363. )
  364. except (RequestSendFailed, HttpResponseException):
  365. raise SynapseError(502, "Failed to fetch room list")
  366. if search_filter:
  367. res = {
  368. "chunk": [
  369. entry
  370. for entry in list(res.get("chunk", []))
  371. if _matches_room_entry(entry, search_filter)
  372. ]
  373. }
  374. return res
  375. async def _get_remote_list_cached(
  376. self,
  377. server_name: str,
  378. limit: Optional[int] = None,
  379. since_token: Optional[str] = None,
  380. search_filter: Optional[dict] = None,
  381. include_all_networks: bool = False,
  382. third_party_instance_id: Optional[str] = None,
  383. ) -> JsonDict:
  384. """Wrapper around FederationClient.get_public_rooms that caches the
  385. result.
  386. """
  387. repl_layer = self.hs.get_federation_client()
  388. if search_filter:
  389. # We can't cache when asking for search
  390. return await repl_layer.get_public_rooms(
  391. server_name,
  392. limit=limit,
  393. since_token=since_token,
  394. search_filter=search_filter,
  395. include_all_networks=include_all_networks,
  396. third_party_instance_id=third_party_instance_id,
  397. )
  398. key = (
  399. server_name,
  400. limit,
  401. since_token,
  402. include_all_networks,
  403. third_party_instance_id,
  404. )
  405. return await self.remote_response_cache.wrap(
  406. key,
  407. repl_layer.get_public_rooms,
  408. server_name,
  409. limit=limit,
  410. since_token=since_token,
  411. search_filter=search_filter,
  412. include_all_networks=include_all_networks,
  413. third_party_instance_id=third_party_instance_id,
  414. )
  415. @attr.s(slots=True, frozen=True, auto_attribs=True)
  416. class RoomListNextBatch:
  417. last_joined_members: int # The count to get rooms after/before
  418. last_room_id: str # The room_id to get rooms after/before
  419. direction_is_forward: bool # True if this is a next_batch, false if prev_batch
  420. KEY_DICT = {
  421. "last_joined_members": "m",
  422. "last_room_id": "r",
  423. "direction_is_forward": "d",
  424. }
  425. REVERSE_KEY_DICT = {v: k for k, v in KEY_DICT.items()}
  426. @classmethod
  427. def from_token(cls, token: str) -> "RoomListNextBatch":
  428. decoded = msgpack.loads(decode_base64(token), raw=False)
  429. return RoomListNextBatch(
  430. **{cls.REVERSE_KEY_DICT[key]: val for key, val in decoded.items()}
  431. )
  432. def to_token(self) -> str:
  433. return encode_base64(
  434. msgpack.dumps(
  435. {self.KEY_DICT[key]: val for key, val in attr.asdict(self).items()}
  436. )
  437. )
  438. def copy_and_replace(self, **kwds: Any) -> "RoomListNextBatch":
  439. return attr.evolve(self, **kwds)
  440. def _matches_room_entry(room_entry: JsonDict, search_filter: dict) -> bool:
  441. """Determines whether the given search filter matches a room entry returned over
  442. federation.
  443. Only used if the remote server does not support MSC2197 remote-filtered search, and
  444. hence does not support MSC3827 filtering of `/publicRooms` by room type either.
  445. In this case, we cannot apply the `room_type` filter since no `room_type` field is
  446. returned.
  447. """
  448. if search_filter and search_filter.get(
  449. PublicRoomsFilterFields.GENERIC_SEARCH_TERM, None
  450. ):
  451. generic_search_term = search_filter[
  452. PublicRoomsFilterFields.GENERIC_SEARCH_TERM
  453. ].upper()
  454. if generic_search_term in room_entry.get("name", "").upper():
  455. return True
  456. elif generic_search_term in room_entry.get("topic", "").upper():
  457. return True
  458. elif generic_search_term in room_entry.get("canonical_alias", "").upper():
  459. return True
  460. else:
  461. return True
  462. return False