room_list.py 17 KB

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