From ba5267d88f21fbd972ae7a4f6500ccaae57f8fbf Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Fri, 30 Oct 2026 17:00:07 +0100 Subject: [PATCH] Serve the synthetic join of announcement rooms over /context The event ID given to non-members in /sync could not be loaded, as the event is never persisted. Return it at the position of the room's create event, for the requesting user only. --- synapse/handlers/room.py | 34 +++++++- synapse/handlers/server_announcements.py | 33 ++++--- synapse/rest/client/room.py | 20 +++-- .../rest/client/test_server_announcements.py | 86 ++++++++++++++++++- 4 files changed, 153 insertions(+), 20 deletions(-) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index e5de95a972..b3666c248a 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -75,6 +75,10 @@ from synapse.events.utils import ( copy_and_fixup_power_levels_contents, ) from synapse.handlers.relations import BundledAggregations +from synapse.handlers.server_announcements import ( + make_synthetic_join_event, + make_synthetic_join_event_id, +) from synapse.rest.admin._base import assert_user_is_admin from synapse.streams import EventSource from synapse.types import ( @@ -122,6 +126,7 @@ class EventContext: aggregations: dict[str, BundledAggregations] start: str end: str + synthetic_event: JsonDict | None = None class RoomCreationHandler: @@ -2022,6 +2027,10 @@ class RoomContextHandler: """Retrieves events, pagination tokens and state around a given event in a room. + The requester's synthetic join in an announcement room is never persisted. + It is returned as the `synthetic_event` of the result, at the position of + the room's create event, which is not itself returned to the requester. + Args: requester room_id @@ -2059,13 +2068,35 @@ class RoomContextHandler: is_peeking=is_peeking, ) + # When requesting the context of a synthetic announcement room membership + # the context displayed will be based on the create event + synthetic_join = None + if ( + not use_admin_priviledge + and event_id == make_synthetic_join_event_id(room_id, user.to_string()) + and await self.store.get_server_announcement_id(room_id) is not None + ): + create_event = await self.store.get_create_event_for_room(room_id) + synthetic_join = make_synthetic_join_event( + room_id, user.to_string(), create_event.origin_server_ts + ) + event_id = create_event.event_id + event = await self.store.get_event( event_id, get_prev_content=True, allow_none=True ) if not event: return None - filtered = await filter_evts([event]) + if synthetic_join is not None: + # Here the `event` is the create event of the room. A user peeking at the + # room cannot pass filter_evts() on it, since the history_visibility is set + # after the create event. This is fine as the event is not returned to the + # requester. + filtered = [FilteredEvent.state(event)] + else: + filtered = await filter_evts([event]) + if not filtered: raise AuthError(403, "You don't have permission to access that event.") @@ -2139,6 +2170,7 @@ class RoomContextHandler: end=await token.copy_and_replace(StreamKeyType.ROOM, results.end).to_string( self.store ), + synthetic_event=synthetic_join, ) diff --git a/synapse/handlers/server_announcements.py b/synapse/handlers/server_announcements.py index 6adfe49ee3..688b68c162 100644 --- a/synapse/handlers/server_announcements.py +++ b/synapse/handlers/server_announcements.py @@ -30,6 +30,28 @@ logger = logging.getLogger(__name__) SYNTHETIC_JOIN_EVENT_ID_PREFIX = "$fake_announcement_join" +def make_synthetic_join_event_id(room_id: str, user_id: str) -> str: + """Build the event ID of the synthetic join of a user in an announcement room. + + Args: + room_id: The announcement room. + user_id: The user receiving the event. + + Returns: + The event ID, which is the same every time for a given room and user. + """ + return "-".join( + [ + SYNTHETIC_JOIN_EVENT_ID_PREFIX, + # 8 first chars of room_id and user_id localpart for debugging + room_id[1:9], + UserID.from_string(user_id).localpart[:8], + # hash of room_id + user_id for uniqueness & staying under 255 bytes length + sha256_and_url_safe_base64(f"{room_id}\0{user_id}"), + ] + ) + + def make_synthetic_join_event( room_id: str, user_id: str, origin_server_ts: int ) -> JsonDict: @@ -55,16 +77,7 @@ def make_synthetic_join_event( return { "room_id": room_id, "type": EventTypes.Member, - "event_id": "-".join( - [ - SYNTHETIC_JOIN_EVENT_ID_PREFIX, - # 8 first chars of room_id and user_id localpart for debugging - room_id[1:9], - UserID.from_string(user_id).localpart[:8], - # hash of room_id + user_id for uniqueness & staying under 255 bytes length - sha256_and_url_safe_base64(f"{room_id}\0{user_id}"), - ] - ), + "event_id": make_synthetic_join_event_id(room_id, user_id), "sender": user_id, "state_key": user_id, "origin_server_ts": origin_server_ts, diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index b5dcc8902e..d846a576bc 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -1280,6 +1280,19 @@ class RoomEventContextServlet(RestServlet): serializer_options = await self._event_serializer.create_config( requester=requester ) + + if event_context.synthetic_event is not None: + event = self._event_serializer.serialize_synthetic_event( + event_context.synthetic_event, serializer_options + ) + else: + event = await self._event_serializer.serialize_event( + event_context.event, + time_now, + bundle_aggregations=event_context.aggregations, + config=serializer_options, + ) + results = { "events_before": await self._event_serializer.serialize_events( event_context.events_before, @@ -1287,12 +1300,7 @@ class RoomEventContextServlet(RestServlet): bundle_aggregations=event_context.aggregations, config=serializer_options, ), - "event": await self._event_serializer.serialize_event( - event_context.event, - time_now, - bundle_aggregations=event_context.aggregations, - config=serializer_options, - ), + "event": event, "events_after": await self._event_serializer.serialize_events( event_context.events_after, time_now, diff --git a/tests/rest/client/test_server_announcements.py b/tests/rest/client/test_server_announcements.py index 5649f56e7f..a9de7d04cc 100644 --- a/tests/rest/client/test_server_announcements.py +++ b/tests/rest/client/test_server_announcements.py @@ -18,7 +18,8 @@ from twisted.internet.testing import MemoryReactor import synapse.rest.admin from synapse.api.constants import EventTypes, RelationTypes from synapse.config.server_announcements import ServerAnnouncementRoom -from synapse.rest.client import login, relations, room +from synapse.handlers.server_announcements import make_synthetic_join_event_id +from synapse.rest.client import login, relations, room, sync from synapse.server import HomeServer from synapse.types import JsonDict from synapse.util.clock import Clock @@ -52,12 +53,13 @@ class ServerAnnouncementsHistoryTestCase(unittest.HomeserverTestCase): room.register_servlets, room.register_deprecated_servlets, relations.register_servlets, + sync.register_servlets, ] def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.announcer = self.register_user("announcer", "pass") self.announcer_tok = self.login("announcer", "pass") - reader = self.register_user("reader", "pass") + self.reader = self.register_user("reader", "pass") self.reader_tok = self.login("reader", "pass") room_config = ServerAnnouncementRoom( @@ -82,7 +84,7 @@ class ServerAnnouncementsHistoryTestCase(unittest.HomeserverTestCase): room_topic=room_config.room_topic, room_avatar_url=room_config.room_avatar_url, room_avatar_path=room_config.room_avatar_path, - allowed_user_ids=[self.announcer, reader], + allowed_user_ids=[self.announcer, self.reader], ) ] self.get_success(handler.reconcile_rooms()) @@ -135,6 +137,84 @@ class ServerAnnouncementsHistoryTestCase(unittest.HomeserverTestCase): body = self._get(f"/rooms/{self.room_id}/context/{self.announcement_id}") self.assertEqual(body["event"]["event_id"], self.announcement_id) + def _get_synthetic_join(self) -> JsonDict: + """Get the reader's synthetic join from `/sync`.""" + room = self._get("/sync")["rooms"]["join"][self.room_id] + return next( + event + for event in room["state"]["events"] + if event["type"] == EventTypes.Member and event["state_key"] == self.reader + ) + + def test_context_of_synthetic_join(self) -> None: + """The reader can load the synthetic join they received in `/sync`.""" + join = self._get_synthetic_join() + + body = self._get(f"/rooms/{self.room_id}/context/{join['event_id']}") + self.assertEqual( + body["event"], {**join, "room_id": self.room_id, "user_id": self.reader} + ) + + def test_context_of_synthetic_join_forward_pagination(self) -> None: + """The reader can paginate forwards from the synthetic join to the + announcement.""" + join = self._get_synthetic_join() + + context = self._get(f"/rooms/{self.room_id}/context/{join['event_id']}") + body = self._get( + f"/rooms/{self.room_id}/messages?dir=f&limit=100&from={context['end']}" + ) + self.assertIn( + self.announcement_id, [event["event_id"] for event in body["chunk"]] + ) + + def test_context_of_synthetic_join_surrounding_events(self) -> None: + """The synthetic join comes first in the room, followed by the events the + reader can see.""" + join = self._get_synthetic_join() + + body = self._get(f"/rooms/{self.room_id}/context/{join['event_id']}?limit=100") + self.assertEqual(body["events_before"], []) + self.assertIn( + self.announcement_id, [event["event_id"] for event in body["events_after"]] + ) + + def test_context_of_synthetic_join_of_another_user(self) -> None: + """The reader cannot load the synthetic join of another user.""" + event_id = make_synthetic_join_event_id(self.room_id, self.announcer) + + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/context/{event_id}", + access_token=self.reader_tok, + ) + self.assertEqual(channel.code, 404, channel.json_body) + + def test_context_of_synthetic_join_after_promotion(self) -> None: + """The reader can still load the synthetic join they received in `/sync` + after a configuration change makes them a sender, and so a member.""" + join = self._get_synthetic_join() + + config = self.hs.config.serverannouncements + room_config = config.rooms[0] + config.rooms = [ + ServerAnnouncementRoom( + config_id=room_config.config_id, + room_name=room_config.room_name, + room_topic=room_config.room_topic, + room_avatar_url=room_config.room_avatar_url, + room_avatar_path=room_config.room_avatar_path, + allowed_user_ids=[self.announcer, self.reader], + ) + ] + self.get_success(self.hs.get_server_announcements_handler().reconcile_rooms()) + self.assertIn(self.room_id, self._get("/joined_rooms")["joined_rooms"]) + + body = self._get(f"/rooms/{self.room_id}/context/{join['event_id']}") + self.assertEqual( + body["event"], {**join, "room_id": self.room_id, "user_id": self.reader} + ) + def test_event(self) -> None: """The reader can fetch the announcement by its ID.""" body = self._get(f"/rooms/{self.room_id}/event/{self.announcement_id}")