Skip to content

Commit b548f78

Browse files
richvdhanoadragon453
andauthoredApr 29, 2024··
Add support for MSC4115 (#17104)
Co-authored-by: Andrew Morgan <[email protected]>
1 parent 758aec6 commit b548f78

20 files changed

+407
-125
lines changed
 

‎changelog.d/17104.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for MSC4115 (membership metadata on events).

‎docker/complement/conf/workers-shared-extra.yaml.j2

+2-2
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@ allow_device_name_lookup_over_federation: true
9292
## Experimental Features ##
9393

9494
experimental_features:
95-
# client-side support for partial state in /send_join responses
96-
faster_joins: true
9795
# Enable support for polls
9896
msc3381_polls_enabled: true
9997
# Enable deleting device-specific notification settings stored in account data
@@ -105,6 +103,8 @@ experimental_features:
105103
# no UIA for x-signing upload for the first time
106104
msc3967_enabled: true
107105

106+
msc4115_membership_on_events: true
107+
108108
server_notices:
109109
system_mxid_localpart: _server
110110
system_mxid_display_name: "Server Alert"

‎rust/src/events/internal_metadata.rs

+7-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020

2121
//! Implements the internal metadata class attached to events.
2222
//!
23-
//! The internal metadata is a bit like a `TypedDict`, in that it is stored as a
24-
//! JSON dict in the DB. Most events have zero, or only a few, of these keys
23+
//! The internal metadata is a bit like a `TypedDict`, in that most of
24+
//! it is stored as a JSON dict in the DB (the exceptions being `outlier`
25+
//! and `stream_ordering` which have their own columns in the database).
26+
//! Most events have zero, or only a few, of these keys
2527
//! set. Therefore, since we care more about memory size than performance here,
2628
//! we store these fields in a mapping.
2729
//!
@@ -234,6 +236,9 @@ impl EventInternalMetadata {
234236
self.clone()
235237
}
236238

239+
/// Get a dict holding the data stored in the `internal_metadata` column in the database.
240+
///
241+
/// Note that `outlier` and `stream_ordering` are stored in separate columns so are not returned here.
237242
fn get_dict(&self, py: Python<'_>) -> PyResult<PyObject> {
238243
let dict = PyDict::new(py);
239244

‎synapse/api/constants.py

+7
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,13 @@ class EventContentFields:
234234
TO_DEVICE_MSGID: Final = "org.matrix.msgid"
235235

236236

237+
class EventUnsignedContentFields:
238+
"""Fields found inside the 'unsigned' data on events"""
239+
240+
# Requesting user's membership, per MSC4115
241+
MSC4115_MEMBERSHIP: Final = "io.element.msc4115.membership"
242+
243+
237244
class RoomTypes:
238245
"""Understood values of the room_type field of m.room.create events."""
239246

‎synapse/config/experimental.py

+4
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,7 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
432432
"You cannot have MSC4108 both enabled and delegated at the same time",
433433
("experimental", "msc4108_delegation_endpoint"),
434434
)
435+
436+
self.msc4115_membership_on_events = experimental.get(
437+
"msc4115_membership_on_events", False
438+
)

‎synapse/events/utils.py

+25-5
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from synapse.api.room_versions import RoomVersion
5050
from synapse.types import JsonDict, Requester
5151

52-
from . import EventBase
52+
from . import EventBase, make_event_from_dict
5353

5454
if TYPE_CHECKING:
5555
from synapse.handlers.relations import BundledAggregations
@@ -82,17 +82,14 @@ def prune_event(event: EventBase) -> EventBase:
8282
"""
8383
pruned_event_dict = prune_event_dict(event.room_version, event.get_dict())
8484

85-
from . import make_event_from_dict
86-
8785
pruned_event = make_event_from_dict(
8886
pruned_event_dict, event.room_version, event.internal_metadata.get_dict()
8987
)
9088

91-
# copy the internal fields
89+
# Copy the bits of `internal_metadata` that aren't returned by `get_dict`
9290
pruned_event.internal_metadata.stream_ordering = (
9391
event.internal_metadata.stream_ordering
9492
)
95-
9693
pruned_event.internal_metadata.outlier = event.internal_metadata.outlier
9794

9895
# Mark the event as redacted
@@ -101,6 +98,29 @@ def prune_event(event: EventBase) -> EventBase:
10198
return pruned_event
10299

103100

101+
def clone_event(event: EventBase) -> EventBase:
102+
"""Take a copy of the event.
103+
104+
This is mostly useful because it does a *shallow* copy of the `unsigned` data,
105+
which means it can then be updated without corrupting the in-memory cache. Note that
106+
other properties of the event, such as `content`, are *not* (currently) copied here.
107+
"""
108+
# XXX: We rely on at least one of `event.get_dict()` and `make_event_from_dict()`
109+
# making a copy of `unsigned`. Currently, both do, though I don't really know why.
110+
# Still, as long as they do, there's not much point doing yet another copy here.
111+
new_event = make_event_from_dict(
112+
event.get_dict(), event.room_version, event.internal_metadata.get_dict()
113+
)
114+
115+
# Copy the bits of `internal_metadata` that aren't returned by `get_dict`.
116+
new_event.internal_metadata.stream_ordering = (
117+
event.internal_metadata.stream_ordering
118+
)
119+
new_event.internal_metadata.outlier = event.internal_metadata.outlier
120+
121+
return new_event
122+
123+
104124
def prune_event_dict(room_version: RoomVersion, event_dict: JsonDict) -> JsonDict:
105125
"""Redacts the event_dict in the same way as `prune_event`, except it
106126
operates on dicts rather than event objects

‎synapse/handlers/admin.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(self, hs: "HomeServer"):
4242
self._device_handler = hs.get_device_handler()
4343
self._storage_controllers = hs.get_storage_controllers()
4444
self._state_storage_controller = self._storage_controllers.state
45+
self._hs_config = hs.config
4546
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
4647

4748
async def get_whois(self, user: UserID) -> JsonMapping:
@@ -217,7 +218,10 @@ async def export_user_data(self, user_id: str, writer: "ExfiltrationWriter") ->
217218
)
218219

219220
events = await filter_events_for_client(
220-
self._storage_controllers, user_id, events
221+
self._storage_controllers,
222+
user_id,
223+
events,
224+
msc4115_membership_on_events=self._hs_config.experimental.msc4115_membership_on_events,
221225
)
222226

223227
writer.write_events(room_id, events)

‎synapse/handlers/events.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ class EventHandler:
148148
def __init__(self, hs: "HomeServer"):
149149
self.store = hs.get_datastores().main
150150
self._storage_controllers = hs.get_storage_controllers()
151+
self._config = hs.config
151152

152153
async def get_event(
153154
self,
@@ -189,7 +190,11 @@ async def get_event(
189190
is_peeking = not is_user_in_room
190191

191192
filtered = await filter_events_for_client(
192-
self._storage_controllers, user.to_string(), [event], is_peeking=is_peeking
193+
self._storage_controllers,
194+
user.to_string(),
195+
[event],
196+
is_peeking=is_peeking,
197+
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
193198
)
194199

195200
if not filtered:

‎synapse/handlers/initial_sync.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ async def handle_room(event: RoomsForUser) -> None:
221221
).addErrback(unwrapFirstError)
222222

223223
messages = await filter_events_for_client(
224-
self._storage_controllers, user_id, messages
224+
self._storage_controllers,
225+
user_id,
226+
messages,
227+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
225228
)
226229

227230
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)
@@ -380,6 +383,7 @@ async def _room_initial_sync_parted(
380383
requester.user.to_string(),
381384
messages,
382385
is_peeking=is_peeking,
386+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
383387
)
384388

385389
start_token = StreamToken.START.copy_and_replace(StreamKeyType.ROOM, token)
@@ -494,6 +498,7 @@ async def get_receipts() -> List[JsonMapping]:
494498
requester.user.to_string(),
495499
messages,
496500
is_peeking=is_peeking,
501+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
497502
)
498503

499504
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)

‎synapse/handlers/pagination.py

+1
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,7 @@ async def get_messages(
623623
user_id,
624624
events,
625625
is_peeking=(member_event_id is None),
626+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
626627
)
627628

628629
# if after the filter applied there are no more events

‎synapse/handlers/relations.py

+3
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(self, hs: "HomeServer"):
9595
self._event_handler = hs.get_event_handler()
9696
self._event_serializer = hs.get_event_client_serializer()
9797
self._event_creation_handler = hs.get_event_creation_handler()
98+
self._config = hs.config
9899

99100
async def get_relations(
100101
self,
@@ -163,6 +164,7 @@ async def get_relations(
163164
user_id,
164165
events,
165166
is_peeking=(member_event_id is None),
167+
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
166168
)
167169

168170
# The relations returned for the requested event do include their
@@ -608,6 +610,7 @@ async def get_threads(
608610
user_id,
609611
events,
610612
is_peeking=(member_event_id is None),
613+
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
611614
)
612615

613616
aggregations = await self.get_bundled_aggregations(

‎synapse/handlers/room.py

+1
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,7 @@ async def filter_evts(events: List[EventBase]) -> List[EventBase]:
14761476
user.to_string(),
14771477
events,
14781478
is_peeking=is_peeking,
1479+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
14791480
)
14801481

14811482
event = await self.store.get_event(

‎synapse/handlers/search.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,10 @@ async def _search_by_rank(
480480
filtered_events = await search_filter.filter([r["event"] for r in results])
481481

482482
events = await filter_events_for_client(
483-
self._storage_controllers, user.to_string(), filtered_events
483+
self._storage_controllers,
484+
user.to_string(),
485+
filtered_events,
486+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
484487
)
485488

486489
events.sort(key=lambda e: -rank_map[e.event_id])
@@ -579,7 +582,10 @@ async def _search_by_recent(
579582
filtered_events = await search_filter.filter([r["event"] for r in results])
580583

581584
events = await filter_events_for_client(
582-
self._storage_controllers, user.to_string(), filtered_events
585+
self._storage_controllers,
586+
user.to_string(),
587+
filtered_events,
588+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
583589
)
584590

585591
room_events.extend(events)
@@ -664,11 +670,17 @@ async def _calculate_event_contexts(
664670
)
665671

666672
events_before = await filter_events_for_client(
667-
self._storage_controllers, user.to_string(), res.events_before
673+
self._storage_controllers,
674+
user.to_string(),
675+
res.events_before,
676+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
668677
)
669678

670679
events_after = await filter_events_for_client(
671-
self._storage_controllers, user.to_string(), res.events_after
680+
self._storage_controllers,
681+
user.to_string(),
682+
res.events_after,
683+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
672684
)
673685

674686
context: JsonDict = {

‎synapse/handlers/sync.py

+2
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ async def _load_filtered_recents(
596596
sync_config.user.to_string(),
597597
recents,
598598
always_include_ids=current_state_ids,
599+
msc4115_membership_on_events=self.hs_config.experimental.msc4115_membership_on_events,
599600
)
600601
log_kv({"recents_after_visibility_filtering": len(recents)})
601602
else:
@@ -681,6 +682,7 @@ async def _load_filtered_recents(
681682
sync_config.user.to_string(),
682683
loaded_recents,
683684
always_include_ids=current_state_ids,
685+
msc4115_membership_on_events=self.hs_config.experimental.msc4115_membership_on_events,
684686
)
685687

686688
loaded_recents = []

‎synapse/notifier.py

+1
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ async def check_for_updates(
721721
user.to_string(),
722722
new_events,
723723
is_peeking=is_peeking,
724+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
724725
)
725726
elif keyname == StreamKeyType.PRESENCE:
726727
now = self.clock.time_msec()

‎synapse/push/mailer.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,10 @@ async def _get_notif_vars(
529529
}
530530

531531
the_events = await filter_events_for_client(
532-
self._storage_controllers, user_id, results.events_before
532+
self._storage_controllers,
533+
user_id,
534+
results.events_before,
535+
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
533536
)
534537
the_events.append(notif_event)
535538

‎synapse/visibility.py

+58-15
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@
3636

3737
import attr
3838

39-
from synapse.api.constants import EventTypes, HistoryVisibility, Membership
39+
from synapse.api.constants import (
40+
EventTypes,
41+
EventUnsignedContentFields,
42+
HistoryVisibility,
43+
Membership,
44+
)
4045
from synapse.events import EventBase
4146
from synapse.events.snapshot import EventContext
42-
from synapse.events.utils import prune_event
47+
from synapse.events.utils import clone_event, prune_event
4348
from synapse.logging.opentracing import trace
4449
from synapse.storage.controllers import StorageControllers
4550
from synapse.storage.databases.main import DataStore
@@ -77,6 +82,7 @@ async def filter_events_for_client(
7782
is_peeking: bool = False,
7883
always_include_ids: FrozenSet[str] = frozenset(),
7984
filter_send_to_client: bool = True,
85+
msc4115_membership_on_events: bool = False,
8086
) -> List[EventBase]:
8187
"""
8288
Check which events a user is allowed to see. If the user can see the event but its
@@ -95,9 +101,12 @@ async def filter_events_for_client(
95101
filter_send_to_client: Whether we're checking an event that's going to be
96102
sent to a client. This might not always be the case since this function can
97103
also be called to check whether a user can see the state at a given point.
104+
msc4115_membership_on_events: Whether to include the requesting user's
105+
membership in the "unsigned" data, per MSC4115.
98106
99107
Returns:
100-
The filtered events.
108+
The filtered events. If `msc4115_membership_on_events` is true, the `unsigned`
109+
data is annotated with the membership state of `user_id` at each event.
101110
"""
102111
# Filter out events that have been soft failed so that we don't relay them
103112
# to clients.
@@ -134,21 +143,54 @@ async def filter_events_for_client(
134143
)
135144

136145
def allowed(event: EventBase) -> Optional[EventBase]:
137-
return _check_client_allowed_to_see_event(
146+
state_after_event = event_id_to_state.get(event.event_id)
147+
filtered = _check_client_allowed_to_see_event(
138148
user_id=user_id,
139149
event=event,
140150
clock=storage.main.clock,
141151
filter_send_to_client=filter_send_to_client,
142152
sender_ignored=event.sender in ignore_list,
143153
always_include_ids=always_include_ids,
144154
retention_policy=retention_policies[room_id],
145-
state=event_id_to_state.get(event.event_id),
155+
state=state_after_event,
146156
is_peeking=is_peeking,
147157
sender_erased=erased_senders.get(event.sender, False),
148158
)
159+
if filtered is None:
160+
return None
161+
162+
if not msc4115_membership_on_events:
163+
return filtered
164+
165+
# Annotate the event with the user's membership after the event.
166+
#
167+
# Normally we just look in `state_after_event`, but if the event is an outlier
168+
# we won't have such a state. The only outliers that are returned here are the
169+
# user's own membership event, so we can just inspect that.
170+
171+
user_membership_event: Optional[EventBase]
172+
if event.type == EventTypes.Member and event.state_key == user_id:
173+
user_membership_event = event
174+
elif state_after_event is not None:
175+
user_membership_event = state_after_event.get((EventTypes.Member, user_id))
176+
else:
177+
# unreachable!
178+
raise Exception("Missing state for event that is not user's own membership")
179+
180+
user_membership = (
181+
user_membership_event.membership
182+
if user_membership_event
183+
else Membership.LEAVE
184+
)
149185

150-
# Check each event: gives an iterable of None or (a potentially modified)
151-
# EventBase.
186+
# Copy the event before updating the unsigned data: this shouldn't be persisted
187+
# to the cache!
188+
cloned = clone_event(filtered)
189+
cloned.unsigned[EventUnsignedContentFields.MSC4115_MEMBERSHIP] = user_membership
190+
191+
return cloned
192+
193+
# Check each event: gives an iterable of None or (a modified) EventBase.
152194
filtered_events = map(allowed, events)
153195

154196
# Turn it into a list and remove None entries before returning.
@@ -396,7 +438,13 @@ def _check_client_allowed_to_see_event(
396438

397439
@attr.s(frozen=True, slots=True, auto_attribs=True)
398440
class _CheckMembershipReturn:
399-
"Return value of _check_membership"
441+
"""Return value of `_check_membership`.
442+
443+
Attributes:
444+
allowed: Whether the user should be allowed to see the event.
445+
joined: Whether the user was joined to the room at the event.
446+
"""
447+
400448
allowed: bool
401449
joined: bool
402450

@@ -408,12 +456,7 @@ def _check_membership(
408456
state: StateMap[EventBase],
409457
is_peeking: bool,
410458
) -> _CheckMembershipReturn:
411-
"""Check whether the user can see the event due to their membership
412-
413-
Returns:
414-
True if they can, False if they can't, plus the membership of the user
415-
at the event.
416-
"""
459+
"""Check whether the user can see the event due to their membership"""
417460
# If the event is the user's own membership event, use the 'most joined'
418461
# membership
419462
membership = None
@@ -435,7 +478,7 @@ def _check_membership(
435478
if membership == "leave" and (
436479
prev_membership == "join" or prev_membership == "invite"
437480
):
438-
return _CheckMembershipReturn(True, membership == Membership.JOIN)
481+
return _CheckMembershipReturn(True, False)
439482

440483
new_priority = MEMBERSHIP_PRIORITY.index(membership)
441484
old_priority = MEMBERSHIP_PRIORITY.index(prev_membership)

‎tests/events/test_utils.py

+24
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
PowerLevelsContent,
3333
SerializeEventConfig,
3434
_split_field,
35+
clone_event,
3536
copy_and_fixup_power_levels_contents,
3637
maybe_upsert_event_field,
3738
prune_event,
@@ -611,6 +612,29 @@ def test_relations(self) -> None:
611612
)
612613

613614

615+
class CloneEventTestCase(stdlib_unittest.TestCase):
616+
def test_unsigned_is_copied(self) -> None:
617+
original = make_event_from_dict(
618+
{
619+
"type": "A",
620+
"event_id": "$test:domain",
621+
"unsigned": {"a": 1, "b": 2},
622+
},
623+
RoomVersions.V1,
624+
{"txn_id": "txn"},
625+
)
626+
original.internal_metadata.stream_ordering = 1234
627+
self.assertEqual(original.internal_metadata.stream_ordering, 1234)
628+
629+
cloned = clone_event(original)
630+
cloned.unsigned["b"] = 3
631+
632+
self.assertEqual(original.unsigned, {"a": 1, "b": 2})
633+
self.assertEqual(cloned.unsigned, {"a": 1, "b": 3})
634+
self.assertEqual(cloned.internal_metadata.stream_ordering, 1234)
635+
self.assertEqual(cloned.internal_metadata.txn_id, "txn")
636+
637+
614638
class SerializeEventTestCase(stdlib_unittest.TestCase):
615639
def serialize(self, ev: EventBase, fields: Optional[List[str]]) -> JsonDict:
616640
return serialize_event(

‎tests/rest/client/test_retention.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,12 @@ def test_visibility(self) -> None:
163163
)
164164
self.assertEqual(2, len(events), "events retrieved from database")
165165
filtered_events = self.get_success(
166-
filter_events_for_client(storage_controllers, self.user_id, events)
166+
filter_events_for_client(
167+
storage_controllers,
168+
self.user_id,
169+
events,
170+
msc4115_membership_on_events=True,
171+
)
167172
)
168173

169174
# We should only get one event back.

‎tests/test_visibility.py

+228-92
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.