diff --git a/synapse/visibility.py b/synapse/visibility.py index 5ba2a14a24..cbcbd0ba0a 100644 --- a/synapse/visibility.py +++ b/synapse/visibility.py @@ -42,12 +42,13 @@ from synapse.events.utils import clone_event, prune_event from synapse.logging.opentracing import trace from synapse.storage.controllers import StorageControllers from synapse.storage.databases.main import DataStore +from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.synapse_rust.events import event_visible_to_server from synapse.types import ( - RetentionPolicy, + EventID, RetentionPolicy, StateMap, StrCollection, - get_domain_from_id, + UserID, get_domain_from_id, ) from synapse.types.state import StateFilter from synapse.util.clock import Clock @@ -175,15 +176,21 @@ async def filter_and_transform_events_for_client( retention_policies[ room_id ] = await storage.main.get_retention_policy_for_room(room_id) + sender_trees: dict[str, set[str]] = {} def allowed(event: EventBase) -> EventBase | None: state_after_event = event_id_to_state.get(event.event_id) + sender_ignored = event.sender in ignore_list + if not sender_ignored and (tree := sender_trees.get(event.event_id)): + logger.debug("%s is not in %r, checking if any senders in %r are", event.sender, ignore_list, tree) + sender_ignored = any(sender in ignore_list for sender in tree) + logger.debug("any sender in ignore list: %r", sender_ignored) filtered = _check_client_allowed_to_see_event( user_id=user_id, event=event, clock=storage.main.clock, filter_send_to_client=filter_send_to_client, - sender_ignored=event.sender in ignore_list, + sender_ignored=sender_ignored, always_include_ids=always_include_ids, retention_policy=retention_policies[event.room_id], state=state_after_event, @@ -255,6 +262,46 @@ async def filter_and_transform_events_for_client( return cloned # Check each event: gives an iterable of None or (a modified) EventBase. + event_map = {ev.event_id: ev for ev in events} + async def walk_relations(root: EventBase, senders: set[str]) -> set[str]: + senders.add(root.sender) + logger.debug("Walking relations of %s (senders: %r)", root.event_id, senders) + relations = root.unsigned.get("m.relates_to") + if not isinstance(relations, dict): + logger.debug("No more relations to walk (no relates_to)") + return senders + reply_to = relations.get("m.in_reply_to") + if not isinstance(reply_to, dict): + logger.debug("No more relations to walk (no in_reply_to)") + return senders + reply_id = reply_to.get("event_id") + if not isinstance(reply_id, str): + logger.debug("No more relations to walk (no event_id)") + return senders + reply = event_map.get( + reply_id, + await storage.main.get_event( + reply_id, + redact_behaviour=EventRedactBehaviour.as_is, + allow_rejected=True, + allow_none=True + ) + ) + if reply is None: + logger.debug("No more relations to walk (reply to unknown event)") + return senders + # If this reply is already in sender_trees, we've already walked it, and can + # return now. + if reply in sender_trees: + logger.debug("Already seen reply, no more relations to walk.") + return senders + return await walk_relations(reply, senders) + + for ev in events: + logger.debug("Preparing to walk relations of proposed event %s", ev.event_id) + sender_trees.setdefault(ev.event_id, set()) + await walk_relations(ev, sender_trees[ev.event_id]) + filtered_events = map(allowed, events) # Turn it into a list and remove None entries before returning. diff --git a/tests/rest/client/test_sync.py b/tests/rest/client/test_sync.py index e6ada1adb2..d2de8c940d 100644 --- a/tests/rest/client/test_sync.py +++ b/tests/rest/client/test_sync.py @@ -32,7 +32,10 @@ from synapse.api.constants import ( ReceiptTypes, RelationTypes, ) -from synapse.rest.client import devices, knock, login, read_marker, receipts, room, sync +from synapse.rest.client import ( + account_data, devices, knock, login, read_marker, + receipts, room, sync, +) from synapse.server import HomeServer from synapse.types import JsonDict from synapse.util.clock import Clock @@ -1208,3 +1211,105 @@ class SyncCancellationTestCase(unittest.HomeserverTestCase): ) self.assertEqual(200, channel.code, msg=channel.result["body"]) + +class StarnapseSyncIgnoreSenderTreeTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + sync.register_servlets, + room.register_servlets, + account_data.register_servlets, + ] + + def test_initial_sync(self) -> None: + """Tests that a reply to an ignored user is omitted from sync""" + alice = self.register_user("alice", "password") + bob = self.register_user("bob", "password") + charlie = self.register_user("charlie", "password") + alice_token = self.login("alice", "password") + bob_token = self.login("bob", "password") + charlie_token = self.login("charlie", "password") + + # Create a room and join everyone to it + room_id = self.helper.create_room_as(alice, tok=alice_token) + self.helper.join(room_id, bob, tok=bob_token) + self.helper.join(room_id, charlie, tok=charlie_token) + + # Alice ignores bob + self.helper.set_account_data( + alice, + "m.ignored_user_list", + { + bob: {} + }, + tok=alice_token + ) + + # Alice sends a message + alice_msg = self.helper.send(room_id, alice, tok=alice_token)["event_id"] + # And Bob replies to it + bob_msg = self.helper.send_event( + room_id, + bob, + { + "msgtype": "m.text", + "body": f"Hello {alice} from Bob!", + "m.relates_to": { + "m.in_reply_to": { + "event_id": alice_msg, + } + } + }, + tok=bob_token + )["event_id"] + # And then Charlie replies to Bob + charlie_msg = self.helper.send_event( + room_id, + bob, + { + "msgtype": "m.text", + "body": f"Hello {bob} from Charlie!", + "m.relates_to": { + "m.in_reply_to": { + "event_id": bob_msg, + } + } + }, + tok=charlie_token + )["event_id"] + # Because Alice blocked Bob, she probably doesn't want to see Charlie's reply + # to his message either. Neither Bob nor Charlie's message should show up in + # the next sync. + + # However, we want to make sure a reply from Charlie to Alice doesn't + # get dropped. + charlie_msg2 = self.helper.send_event( + room_id, + bob, + { + "msgtype": "m.text", + "body": f"Hello {alice} from Charlie!", + "m.relates_to": { + "m.in_reply_to": { + "event_id": alice_msg, + } + } + }, + tok=charlie_token + )["event_id"] + charlie_msg3 = self.helper.send(room_id, charlie, tok=charlie_token)["event_id"] + + + # Request an initial sync + channel = self.make_request("GET", "/sync", access_token=alice_token) + self.assertEqual(channel.code, 200, channel.json_body) + + # Check for those one time key counts + timeline = channel.json_body["rooms"]["join"][room_id]["timeline"]["events"] + timeline_map = {e["event_id"]: e for e in timeline if e.get("state_key") is None} + print("\nTimeline map: %s\nAlice: %s\nBob: %s\nCharlie: %s %s %s" % (json.dumps(timeline_map, indent=4), alice_msg, bob_msg, charlie_msg, charlie_msg2, charlie_msg3)) + assert bob_msg not in timeline_map, "Bob bypassed ignore list" + assert charlie_msg not in timeline_map, "Charlie's reply to bob appeared" + assert alice_msg in timeline_map, "Alice's message should be in the timeline" + assert charlie_msg2 in timeline_map, "Charlie's reply to alice message should be in the timeline" + assert charlie_msg3 in timeline_map, "Charlie's unrelated message should be in the timeline" diff --git a/tests/rest/client/utils.py b/tests/rest/client/utils.py index bfa8e6f3d8..9e24573679 100644 --- a/tests/rest/client/utils.py +++ b/tests/rest/client/utils.py @@ -1022,3 +1022,26 @@ class RestHelper: access_token=tok, ) assert channel.code == HTTPStatus.OK, channel.text_body + + def set_account_data( + self, + user_id: str, + type: str, + content: dict, + room_id: str | None = None, + *, + tok: str + ) -> None: + if room_id is None: + path = f"/user/{user_id}/account_data/{type}" + else: + path = f"/user/{user_id}/rooms/{room_id}/account_data/{type}" + channel = make_request( + self.reactor, + self.site, + method="PUT", + path=path, + content=content, + access_token=tok, + ) + assert channel.code == HTTPStatus.OK, channel.text_body