room_list.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  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 six import PY3, iteritems
  18. from six.moves import range
  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.types import ThirdPartyInstanceID
  24. from synapse.util.async_helpers import concurrently_execute
  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. """
  60. if not self.enable_room_list_search:
  61. return defer.succeed({"chunk": [], "total_room_count_estimate": 0})
  62. logger.info(
  63. "Getting public room list: limit=%r, since=%r, search=%r, network=%r",
  64. limit,
  65. since_token,
  66. bool(search_filter),
  67. network_tuple,
  68. )
  69. if search_filter:
  70. # We explicitly don't bother caching searches or requests for
  71. # appservice specific lists.
  72. logger.info("Bypassing cache as search request.")
  73. # XXX: Quick hack to stop room directory queries taking too long.
  74. # Timeout request after 60s. Probably want a more fundamental
  75. # solution at some point
  76. timeout = self.clock.time() + 60
  77. return self._get_public_room_list(
  78. limit,
  79. since_token,
  80. search_filter,
  81. network_tuple=network_tuple,
  82. timeout=timeout,
  83. )
  84. key = (limit, since_token, network_tuple)
  85. return self.response_cache.wrap(
  86. key,
  87. self._get_public_room_list,
  88. limit,
  89. since_token,
  90. network_tuple=network_tuple,
  91. from_federation=from_federation,
  92. )
  93. @defer.inlineCallbacks
  94. def _get_public_room_list(
  95. self,
  96. limit=None,
  97. since_token=None,
  98. search_filter=None,
  99. network_tuple=EMPTY_THIRD_PARTY_ID,
  100. from_federation=False,
  101. timeout=None,
  102. ):
  103. """Generate a public room list.
  104. Args:
  105. limit (int|None): Maximum amount of rooms to return.
  106. since_token (str|None)
  107. search_filter (dict|None): Dictionary to filter rooms by.
  108. network_tuple (ThirdPartyInstanceID): Which public list to use.
  109. This can be (None, None) to indicate the main list, or a particular
  110. appservice and network id to use an appservice specific one.
  111. Setting to None returns all public rooms across all lists.
  112. from_federation (bool): Whether this request originated from a
  113. federating server or a client. Used for room filtering.
  114. timeout (int|None): Amount of seconds to wait for a response before
  115. timing out.
  116. """
  117. if since_token and since_token != "END":
  118. since_token = RoomListNextBatch.from_token(since_token)
  119. else:
  120. since_token = None
  121. rooms_to_order_value = {}
  122. rooms_to_num_joined = {}
  123. newly_visible = []
  124. newly_unpublished = []
  125. if since_token:
  126. stream_token = since_token.stream_ordering
  127. current_public_id = yield self.store.get_current_public_room_stream_id()
  128. public_room_stream_id = since_token.public_room_stream_id
  129. newly_visible, newly_unpublished = yield self.store.get_public_room_changes(
  130. public_room_stream_id, current_public_id, network_tuple=network_tuple
  131. )
  132. else:
  133. stream_token = yield self.store.get_room_max_stream_ordering()
  134. public_room_stream_id = yield self.store.get_current_public_room_stream_id()
  135. room_ids = yield self.store.get_public_room_ids_at_stream_id(
  136. public_room_stream_id, network_tuple=network_tuple
  137. )
  138. # We want to return rooms in a particular order: the number of joined
  139. # users. We then arbitrarily use the room_id as a tie breaker.
  140. @defer.inlineCallbacks
  141. def get_order_for_room(room_id):
  142. # Most of the rooms won't have changed between the since token and
  143. # now (especially if the since token is "now"). So, we can ask what
  144. # the current users are in a room (that will hit a cache) and then
  145. # check if the room has changed since the since token. (We have to
  146. # do it in that order to avoid races).
  147. # If things have changed then fall back to getting the current state
  148. # at the since token.
  149. joined_users = yield self.store.get_users_in_room(room_id)
  150. if self.store.has_room_changed_since(room_id, stream_token):
  151. latest_event_ids = yield self.store.get_forward_extremeties_for_room(
  152. room_id, stream_token
  153. )
  154. if not latest_event_ids:
  155. return
  156. joined_users = yield self.state_handler.get_current_users_in_room(
  157. room_id, latest_event_ids
  158. )
  159. num_joined_users = len(joined_users)
  160. rooms_to_num_joined[room_id] = num_joined_users
  161. if num_joined_users == 0:
  162. return
  163. # We want larger rooms to be first, hence negating num_joined_users
  164. rooms_to_order_value[room_id] = (-num_joined_users, room_id)
  165. logger.info(
  166. "Getting ordering for %i rooms since %s", len(room_ids), stream_token
  167. )
  168. yield concurrently_execute(get_order_for_room, room_ids, 10)
  169. sorted_entries = sorted(rooms_to_order_value.items(), key=lambda e: e[1])
  170. sorted_rooms = [room_id for room_id, _ in sorted_entries]
  171. # `sorted_rooms` should now be a list of all public room ids that is
  172. # stable across pagination. Therefore, we can use indices into this
  173. # list as our pagination tokens.
  174. # Filter out rooms that we don't want to return
  175. rooms_to_scan = [
  176. r
  177. for r in sorted_rooms
  178. if r not in newly_unpublished and rooms_to_num_joined[r] > 0
  179. ]
  180. total_room_count = len(rooms_to_scan)
  181. if since_token:
  182. # Filter out rooms we've already returned previously
  183. # `since_token.current_limit` is the index of the last room we
  184. # sent down, so we exclude it and everything before/after it.
  185. if since_token.direction_is_forward:
  186. rooms_to_scan = rooms_to_scan[since_token.current_limit + 1 :]
  187. else:
  188. rooms_to_scan = rooms_to_scan[: since_token.current_limit]
  189. rooms_to_scan.reverse()
  190. logger.info("After sorting and filtering, %i rooms remain", len(rooms_to_scan))
  191. # _append_room_entry_to_chunk will append to chunk but will stop if
  192. # len(chunk) > limit
  193. #
  194. # Normally we will generate enough results on the first iteration here,
  195. # but if there is a search filter, _append_room_entry_to_chunk may
  196. # filter some results out, in which case we loop again.
  197. #
  198. # We don't want to scan over the entire range either as that
  199. # would potentially waste a lot of work.
  200. #
  201. # XXX if there is no limit, we may end up DoSing the server with
  202. # calls to get_current_state_ids for every single room on the
  203. # server. Surely we should cap this somehow?
  204. #
  205. if limit:
  206. step = limit + 1
  207. else:
  208. # step cannot be zero
  209. step = len(rooms_to_scan) if len(rooms_to_scan) != 0 else 1
  210. chunk = []
  211. for i in range(0, len(rooms_to_scan), step):
  212. if timeout and self.clock.time() > timeout:
  213. raise Exception("Timed out searching room directory")
  214. batch = rooms_to_scan[i : i + step]
  215. logger.info("Processing %i rooms for result", len(batch))
  216. yield concurrently_execute(
  217. lambda r: self._append_room_entry_to_chunk(
  218. r,
  219. rooms_to_num_joined[r],
  220. chunk,
  221. limit,
  222. search_filter,
  223. from_federation=from_federation,
  224. ),
  225. batch,
  226. 5,
  227. )
  228. logger.info("Now %i rooms in result", len(chunk))
  229. if len(chunk) >= limit + 1:
  230. break
  231. chunk.sort(key=lambda e: (-e["num_joined_members"], e["room_id"]))
  232. # Work out the new limit of the batch for pagination, or None if we
  233. # know there are no more results that would be returned.
  234. # i.e., [since_token.current_limit..new_limit] is the batch of rooms
  235. # we've returned (or the reverse if we paginated backwards)
  236. # We tried to pull out limit + 1 rooms above, so if we have <= limit
  237. # then we know there are no more results to return
  238. new_limit = None
  239. if chunk and (not limit or len(chunk) > limit):
  240. if not since_token or since_token.direction_is_forward:
  241. if limit:
  242. chunk = chunk[:limit]
  243. last_room_id = chunk[-1]["room_id"]
  244. else:
  245. if limit:
  246. chunk = chunk[-limit:]
  247. last_room_id = chunk[0]["room_id"]
  248. new_limit = sorted_rooms.index(last_room_id)
  249. results = {"chunk": chunk, "total_room_count_estimate": total_room_count}
  250. if since_token:
  251. results["new_rooms"] = bool(newly_visible)
  252. if not since_token or since_token.direction_is_forward:
  253. if new_limit is not None:
  254. results["next_batch"] = RoomListNextBatch(
  255. stream_ordering=stream_token,
  256. public_room_stream_id=public_room_stream_id,
  257. current_limit=new_limit,
  258. direction_is_forward=True,
  259. ).to_token()
  260. if since_token:
  261. results["prev_batch"] = since_token.copy_and_replace(
  262. direction_is_forward=False,
  263. current_limit=since_token.current_limit + 1,
  264. ).to_token()
  265. else:
  266. if new_limit is not None:
  267. results["prev_batch"] = RoomListNextBatch(
  268. stream_ordering=stream_token,
  269. public_room_stream_id=public_room_stream_id,
  270. current_limit=new_limit,
  271. direction_is_forward=False,
  272. ).to_token()
  273. if since_token:
  274. results["next_batch"] = since_token.copy_and_replace(
  275. direction_is_forward=True,
  276. current_limit=since_token.current_limit - 1,
  277. ).to_token()
  278. return results
  279. @defer.inlineCallbacks
  280. def _append_room_entry_to_chunk(
  281. self,
  282. room_id,
  283. num_joined_users,
  284. chunk,
  285. limit,
  286. search_filter,
  287. from_federation=False,
  288. ):
  289. """Generate the entry for a room in the public room list and append it
  290. to the `chunk` if it matches the search filter
  291. Args:
  292. room_id (str): The ID of the room.
  293. num_joined_users (int): The number of joined users in the room.
  294. chunk (list)
  295. limit (int|None): Maximum amount of rooms to display. Function will
  296. return if length of chunk is greater than limit + 1.
  297. search_filter (dict|None)
  298. from_federation (bool): Whether this request originated from a
  299. federating server or a client. Used for room filtering.
  300. """
  301. if limit and len(chunk) > limit + 1:
  302. # We've already got enough, so lets just drop it.
  303. return
  304. result = yield self.generate_room_entry(room_id, num_joined_users)
  305. if not result:
  306. return
  307. if from_federation and not result.get("m.federate", True):
  308. # This is a room that other servers cannot join. Do not show them
  309. # this room.
  310. return
  311. if _matches_room_entry(result, search_filter):
  312. chunk.append(result)
  313. @cachedInlineCallbacks(num_args=1, cache_context=True)
  314. def generate_room_entry(
  315. self,
  316. room_id,
  317. num_joined_users,
  318. cache_context,
  319. with_alias=True,
  320. allow_private=False,
  321. ):
  322. """Returns the entry for a room
  323. Args:
  324. room_id (str): The room's ID.
  325. num_joined_users (int): Number of users in the room.
  326. cache_context: Information for cached responses.
  327. with_alias (bool): Whether to return the room's aliases in the result.
  328. allow_private (bool): Whether invite-only rooms should be shown.
  329. Returns:
  330. Deferred[dict|None]: Returns a room entry as a dictionary, or None if this
  331. room was determined not to be shown publicly.
  332. """
  333. result = {"room_id": room_id, "num_joined_members": num_joined_users}
  334. current_state_ids = yield self.store.get_current_state_ids(
  335. room_id, on_invalidate=cache_context.invalidate
  336. )
  337. event_map = yield self.store.get_events(
  338. [
  339. event_id
  340. for key, event_id in iteritems(current_state_ids)
  341. if key[0]
  342. in (
  343. EventTypes.Create,
  344. EventTypes.JoinRules,
  345. EventTypes.Name,
  346. EventTypes.Topic,
  347. EventTypes.CanonicalAlias,
  348. EventTypes.RoomHistoryVisibility,
  349. EventTypes.GuestAccess,
  350. "m.room.avatar",
  351. )
  352. ]
  353. )
  354. current_state = {(ev.type, ev.state_key): ev for ev in event_map.values()}
  355. # Double check that this is actually a public room.
  356. join_rules_event = current_state.get((EventTypes.JoinRules, ""))
  357. if join_rules_event:
  358. join_rule = join_rules_event.content.get("join_rule", None)
  359. if not allow_private and join_rule and join_rule != JoinRules.PUBLIC:
  360. return None
  361. # Return whether this room is open to federation users or not
  362. create_event = current_state.get((EventTypes.Create, ""))
  363. result["m.federate"] = create_event.content.get("m.federate", True)
  364. if with_alias:
  365. aliases = yield self.store.get_aliases_for_room(
  366. room_id, on_invalidate=cache_context.invalidate
  367. )
  368. if aliases:
  369. result["aliases"] = aliases
  370. name_event = yield current_state.get((EventTypes.Name, ""))
  371. if name_event:
  372. name = name_event.content.get("name", None)
  373. if name:
  374. result["name"] = name
  375. topic_event = current_state.get((EventTypes.Topic, ""))
  376. if topic_event:
  377. topic = topic_event.content.get("topic", None)
  378. if topic:
  379. result["topic"] = topic
  380. canonical_event = current_state.get((EventTypes.CanonicalAlias, ""))
  381. if canonical_event:
  382. canonical_alias = canonical_event.content.get("alias", None)
  383. if canonical_alias:
  384. result["canonical_alias"] = canonical_alias
  385. visibility_event = current_state.get((EventTypes.RoomHistoryVisibility, ""))
  386. visibility = None
  387. if visibility_event:
  388. visibility = visibility_event.content.get("history_visibility", None)
  389. result["world_readable"] = visibility == "world_readable"
  390. guest_event = current_state.get((EventTypes.GuestAccess, ""))
  391. guest = None
  392. if guest_event:
  393. guest = guest_event.content.get("guest_access", None)
  394. result["guest_can_join"] = guest == "can_join"
  395. avatar_event = current_state.get(("m.room.avatar", ""))
  396. if avatar_event:
  397. avatar_url = avatar_event.content.get("url", None)
  398. if avatar_url:
  399. result["avatar_url"] = avatar_url
  400. return result
  401. @defer.inlineCallbacks
  402. def get_remote_public_room_list(
  403. self,
  404. server_name,
  405. limit=None,
  406. since_token=None,
  407. search_filter=None,
  408. include_all_networks=False,
  409. third_party_instance_id=None,
  410. ):
  411. if not self.enable_room_list_search:
  412. return {"chunk": [], "total_room_count_estimate": 0}
  413. if search_filter:
  414. # We currently don't support searching across federation, so we have
  415. # to do it manually without pagination
  416. limit = None
  417. since_token = None
  418. res = yield self._get_remote_list_cached(
  419. server_name,
  420. limit=limit,
  421. since_token=since_token,
  422. include_all_networks=include_all_networks,
  423. third_party_instance_id=third_party_instance_id,
  424. )
  425. if search_filter:
  426. res = {
  427. "chunk": [
  428. entry
  429. for entry in list(res.get("chunk", []))
  430. if _matches_room_entry(entry, search_filter)
  431. ]
  432. }
  433. return res
  434. def _get_remote_list_cached(
  435. self,
  436. server_name,
  437. limit=None,
  438. since_token=None,
  439. search_filter=None,
  440. include_all_networks=False,
  441. third_party_instance_id=None,
  442. ):
  443. repl_layer = self.hs.get_federation_client()
  444. if search_filter:
  445. # We can't cache when asking for search
  446. return repl_layer.get_public_rooms(
  447. server_name,
  448. limit=limit,
  449. since_token=since_token,
  450. search_filter=search_filter,
  451. include_all_networks=include_all_networks,
  452. third_party_instance_id=third_party_instance_id,
  453. )
  454. key = (
  455. server_name,
  456. limit,
  457. since_token,
  458. include_all_networks,
  459. third_party_instance_id,
  460. )
  461. return self.remote_response_cache.wrap(
  462. key,
  463. repl_layer.get_public_rooms,
  464. server_name,
  465. limit=limit,
  466. since_token=since_token,
  467. search_filter=search_filter,
  468. include_all_networks=include_all_networks,
  469. third_party_instance_id=third_party_instance_id,
  470. )
  471. class RoomListNextBatch(
  472. namedtuple(
  473. "RoomListNextBatch",
  474. (
  475. "stream_ordering", # stream_ordering of the first public room list
  476. "public_room_stream_id", # public room stream id for first public room list
  477. "current_limit", # The number of previous rooms returned
  478. "direction_is_forward", # Bool if this is a next_batch, false if prev_batch
  479. ),
  480. )
  481. ):
  482. KEY_DICT = {
  483. "stream_ordering": "s",
  484. "public_room_stream_id": "p",
  485. "current_limit": "n",
  486. "direction_is_forward": "d",
  487. }
  488. REVERSE_KEY_DICT = {v: k for k, v in KEY_DICT.items()}
  489. @classmethod
  490. def from_token(cls, token):
  491. if PY3:
  492. # The argument raw=False is only available on new versions of
  493. # msgpack, and only really needed on Python 3. Gate it behind
  494. # a PY3 check to avoid causing issues on Debian-packaged versions.
  495. decoded = msgpack.loads(decode_base64(token), raw=False)
  496. else:
  497. decoded = msgpack.loads(decode_base64(token))
  498. return RoomListNextBatch(
  499. **{cls.REVERSE_KEY_DICT[key]: val for key, val in decoded.items()}
  500. )
  501. def to_token(self):
  502. return encode_base64(
  503. msgpack.dumps(
  504. {self.KEY_DICT[key]: val for key, val in self._asdict().items()}
  505. )
  506. )
  507. def copy_and_replace(self, **kwds):
  508. return self._replace(**kwds)
  509. def _matches_room_entry(room_entry, search_filter):
  510. if search_filter and search_filter.get("generic_search_term", None):
  511. generic_search_term = search_filter["generic_search_term"].upper()
  512. if generic_search_term in room_entry.get("name", "").upper():
  513. return True
  514. elif generic_search_term in room_entry.get("topic", "").upper():
  515. return True
  516. elif generic_search_term in room_entry.get("canonical_alias", "").upper():
  517. return True
  518. else:
  519. return True
  520. return False