Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19958.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a new `auto_accept_invites.enabled_for_accepted_knocks` option which, when enabled, automatically joins a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted), instead of requiring the user to accept the resulting invite by hand; the server designates one of the user's devices to download any MSC4268 room key bundle. Also fix the `on_new_event` module callback breaking event persistence when a rejected event was received over federation.
2 changes: 2 additions & 0 deletions docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4773,6 +4773,8 @@ This setting has the following sub-options:

* `enabled` (boolean): Whether to run the auto-accept invites logic. Defaults to `false`.

* `enabled_for_accepted_knocks` (boolean): Whether to automatically join a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted). The user already asked to join by knocking, so this applies independently of `enabled` and of the restrictions below. Defaults to `false`.

* `only_for_direct_messages` (boolean): Whether invites should be automatically accepted for all room types, or only for direct messages. Defaults to `false`.

* `only_from_local_users` (boolean): Whether to only automatically accept invites from users on this homeserver. Defaults to `false`.
Expand Down
9 changes: 9 additions & 0 deletions schema/synapse-config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5865,6 +5865,15 @@ properties:
type: boolean
description: Whether to run the auto-accept invites logic.
default: false
enabled_for_accepted_knocks:
type: boolean
description: >-
Whether to automatically join a user to a room when an invite
arrives for a knock they have pending in that room (i.e. their
knock was accepted). The user already asked to join by knocking,
so this applies independently of `enabled` and of the restrictions
below.
default: false
only_for_direct_messages:
type: boolean
description: >-
Expand Down
5 changes: 4 additions & 1 deletion synapse/app/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,10 @@ async def start(hs: "HomeServer", *, freeze: bool = True) -> None:
m = module(config, module_api)
logger.info("Loaded module %s", m)

if hs.config.auto_accept_invites.enabled:
if (
hs.config.auto_accept_invites.enabled
or hs.config.auto_accept_invites.enabled_for_accepted_knocks
):
# Start the local auto_accept_invites module.
m = InviteAutoAccepter(hs.config.auto_accept_invites, module_api)
logger.info("Loaded local module %s", m)
Expand Down
10 changes: 10 additions & 0 deletions synapse/config/auto_accept_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:

self.enabled = auto_accept_invites_config.get("enabled", False)

# Whether to automatically join a room on behalf of a user when an
# invite arrives for a knock the user has pending in that room (i.e.
# the knock was accepted). The user already asked to join by knocking,
# so this is independent of `enabled` above and of the DM/local-user
# restrictions below.
# See https://github.com/element-hq/synapse/issues/16307.
self.enabled_for_accepted_knocks = auto_accept_invites_config.get(
"enabled_for_accepted_knocks", False
)

self.accept_invites_only_for_direct_messages = auto_accept_invites_config.get(
"only_for_direct_messages", False
)
Expand Down
121 changes: 107 additions & 14 deletions synapse/events/auto_accept_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,27 @@
from synapse.api.errors import SynapseError
from synapse.config.auto_accept_invites import AutoAcceptInvitesConfig
from synapse.module_api import EventBase, ModuleApi, run_as_background_process
from synapse.types import create_requester

logger = logging.getLogger(__name__)

# MSC4509: to-device message designating one of a user's devices as the eager
# downloader of an MSC4268 room key bundle after a server-initiated join.
UNSTABLE_KEY_BUNDLE_CLAIM_TYPE = "org.matrix.msc4509.key_bundle_claim"


class InviteAutoAccepter:
def __init__(self, config: AutoAcceptInvitesConfig, api: ModuleApi):
# Keep a reference to the Module API.
self._api = api
self.server_name = api.server_name
self._config = config
# This is bundled with Synapse (rather than a true module), so reach
# for the datastore directly: the module API doesn't expose event
# fetching.
self._store = api._store

if not self._config.enabled:
if not self._config.enabled and not self._config.enabled_for_accepted_knocks:
return

should_run_on_this_worker = config.worker_to_run_on == self._api.worker_name
Expand Down Expand Up @@ -75,21 +84,34 @@ async def on_new_event(self, event: EventBase, *args: Any) -> None:
):
return

# Only accept invites for direct messages if the configuration mandates it.
is_direct_message = event.content.get("is_direct", False)
if (
self._config.accept_invites_only_for_direct_messages
and is_direct_message is False
):
# An invite following a knock from the user (the knock being accepted)
# is auto-joined regardless of the general auto-accept settings: the
# user already asked to join the room by knocking.
# See https://github.com/element-hq/synapse/issues/16307.
is_accepted_knock = await self._is_accepted_knock(event)
if is_accepted_knock:
if not self._config.enabled_for_accepted_knocks:
return
elif not self._config.enabled:
return

# Only accept invites from remote users if the configuration mandates it.
is_from_local_user = self._api.is_mine(event.sender)
if (
self._config.accept_invites_only_from_local_users
and is_from_local_user is False
):
return
is_direct_message = event.content.get("is_direct", False)

if not is_accepted_knock:
# Only accept invites for direct messages if the configuration mandates it.
if (
self._config.accept_invites_only_for_direct_messages
and is_direct_message is False
):
return

# Only accept invites from remote users if the configuration mandates it.
is_from_local_user = self._api.is_mine(event.sender)
if (
self._config.accept_invites_only_from_local_users
and is_from_local_user is False
):
return

# Check the user is activated.
recipient = await self._api.get_userinfo_by_id(event.state_key)
Expand Down Expand Up @@ -127,6 +149,26 @@ async def on_new_event(self, event: EventBase, *args: Any) -> None:
event.state_key, event.sender, event.room_id
)

async def _is_accepted_knock(self, invite_event: EventBase) -> bool:
"""Whether the invite is the acceptance of a knock by the invited
user: i.e. the invited user's own knock membership event is among the
invite's auth events.

This works for over-federation invites too: the knocking server holds
the knock event (it created it), and the resident server necessarily
cited it as the invitee's prior membership when authing the invite.
"""
for auth_event_id in invite_event.auth_event_ids():
auth_event = await self._store.get_event(auth_event_id, allow_none=True)
if (
auth_event is not None
and auth_event.type == EventTypes.Member
and auth_event.state_key == invite_event.state_key
and auth_event.membership == Membership.KNOCK
):
return True
return False

async def _mark_room_as_direct_message(
self, user_id: str, dm_user_id: str, room_id: str
) -> None:
Expand Down Expand Up @@ -214,3 +256,54 @@ async def _retry_make_join(

if join_event is not None:
break

if join_event is not None:
# The join was made by the server, so it lands on all of the
# user's devices in the same sync instant. Designate one device to
# eagerly download any MSC4268 room key bundle for the room, lest
# every device does (MSC4509). Best effort: the hint is advisory,
# and without it clients fall back to claiming the bundle lazily.
try:
await self._send_key_bundle_claim_hint(target, room_id)
except Exception as e:
logger.warning(
"Failed to send key bundle claim hint to %s for %s: %s",
target,
room_id,
e,
)

async def _send_key_bundle_claim_hint(self, user_id: str, room_id: str) -> None:
"""Send an `org.matrix.msc4509.key_bundle_claim` to-device message to
the user's most recently active device, designating it as the one
device which should eagerly download an MSC4268 room key bundle for
the room the user was just joined to.

Args:
user_id: the (local) user who was joined to the room
room_id: the room they were joined to
"""
# This is bundled with Synapse (rather than a true module), so reach
# into the homeserver for the device machinery: the module API doesn't
# expose device listing or to-device sending.
hs = self._api._hs
devices = await hs.get_device_handler().get_devices_by_user(user_id)
if not devices:
return

# Pick the device the user is most likely to be actively using. The
# worst a stale pick costs is the bundle being claimed lazily instead.
device = max(devices, key=lambda d: d.get("last_seen_ts") or 0)
device_id = device["device_id"]

logger.info(
"Designating device %s of %s to claim any key bundle for %s",
device_id,
user_id,
room_id,
)
await hs.get_device_message_handler().send_device_message(
create_requester(user_id),
UNSTABLE_KEY_BUNDLE_CLAIM_TYPE,
{user_id: {device_id: {"room_id": room_id}}},
)
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,13 @@ async def on_new_event(self, event_id: str) -> None:
if len(self._on_new_event_callbacks) == 0:
return

event = await self.store.get_event(event_id)
# `allow_rejected` so that a rejected event doesn't raise NotFoundError
# here, which would propagate out of the notifier and break the caller
# (e.g. persisting further federation events). Rejected events are not
# shown to modules.
event = await self.store.get_event(event_id, allow_rejected=True)
if event.rejected_reason is not None:
return

# We *don't* want to wait for the full state here, because waiting for full
# state will persist event, which in turn will call this method.
Expand Down
Loading
Loading